From f1a0efa0665a5fddd044d8e35a719324e59fe493 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 12 Sep 2025 15:43:13 -0400 Subject: [PATCH 01/49] feat: Introduce `ApplicationLaunchMonitor` for correlating app launches to connections in the dev server Added a thread-safe, in-memory tool to track and match application launches to incoming connections, with configurable timeouts and callbacks. Includes documentation and tests for usage and edge cases. --- src/Directory.Build.targets | 1 + .../Given_ApplicationLaunchMonitor.cs | 206 ++++++++++++++ ...no.UI.RemoteControl.DevServer.Tests.csproj | 26 +- .../AppLaunch/ApplicationLaunchMonitor.cs | 263 ++++++++++++++++++ .../AppLaunch/ApplicationLaunchMonitor.md | 49 ++++ 5 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs create mode 100644 src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs create mode 100644 src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index e42eaef9a831..ac848f27f045 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -133,6 +133,7 @@ + diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs new file mode 100644 index 000000000000..4e22180b0525 --- /dev/null +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -0,0 +1,206 @@ +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Uno.UI.RemoteControl.Server.AppLaunch; + +namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch +{ + [TestClass] + public class Given_ApplicationLaunchMonitor + { + private static ApplicationLaunchMonitor CreateMonitor( + out FakeTimeProvider clock, + out List registered, + out List timeouts, + out List connections, + TimeSpan? timeout = null) + { + // Use local lists inside callbacks, then assign them to out parameters + var registeredList = new List(); + var timeoutsList = new List(); + var connectionsList = new List(); + + var options = new ApplicationLaunchMonitor.Options + { + Timeout = timeout ?? TimeSpan.FromSeconds(10), + OnRegistered = e => registeredList.Add(e), + OnTimeout = e => timeoutsList.Add(e), + OnConnected = e => connectionsList.Add(e), + }; + + clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + registered = registeredList; + timeouts = timeoutsList; + connections = connectionsList; + + return new ApplicationLaunchMonitor(clock, options); + } + + [TestMethod] + public void WhenLaunchRegisteredAndNotTimedOut_ThenRegisteredCallbackOnly() + { + // Arrange + using var sut = CreateMonitor(out var clock, out var registered, out var timeouts, out var connections, + TimeSpan.FromSeconds(10)); + var mvid = Guid.NewGuid(); + + // Act + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + clock.Advance(TimeSpan.FromSeconds(5)); + + // Assert + registered.Should().HaveCount(1); + timeouts.Should().BeEmpty(); + connections.Should().BeEmpty(); + } + + [TestMethod] + public void WhenMatchingConnectionReportedForRegisteredLaunch_ThenConnectedInvokedOnceAndConsumed() + { + // Arrange + using var sut = CreateMonitor(out var clock, out var registered, out var timeouts, out var connections); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + + // Act + sut.ReportConnection(mvid, "Wasm", isDebug: true); + clock.Advance(TimeSpan.FromSeconds(500)); + + // Assert + connections.Should().HaveCount(1); + timeouts.Should().BeEmpty(); + + // And: second report should not match (consumed) + sut.ReportConnection(mvid, "Wasm", isDebug: true); + connections.Should().HaveCount(1); + } + + [TestMethod] + public void WhenConnectionsArriveForMultipleRegistrations_ThenFifoOrderIsPreserved() + { + // Arrange: ensure timeouts can expire earlier registrations and FIFO is preserved for active ones + using var sut = CreateMonitor(out var clock, out _, out var timeouts, out var connections, TimeSpan.FromMilliseconds(500)); + var mvid = Guid.NewGuid(); + + // First registration - will be expired + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + // Advance beyond timeout so the first one expires + clock.Advance(TimeSpan.FromMilliseconds(600)); + + // Two active registrations that should remain in FIFO order + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + clock.Advance(TimeSpan.FromMilliseconds(1)); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + + // Act + sut.ReportConnection(mvid, "Wasm", isDebug: true); + sut.ReportConnection(mvid, "Wasm", isDebug: true); + + // Assert + // First registration should have timed out + timeouts.Should().HaveCount(1); + connections.Should().HaveCount(2); + connections[0].RegisteredAt.Should().BeOnOrBefore(connections[1].RegisteredAt); + } + + [TestMethod] + public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stress() + { + // Stress test: large number of registrations where many expire, then a batch of active registrations + const int K = 9000; // number to expire + const int L = 1000; // number to remain active and validate FIFO on + + using var sut = CreateMonitor(out var clock, out _, out var timeouts, out var connections, TimeSpan.FromMilliseconds(100)); + var mvid = Guid.NewGuid(); + + // Register K entries which will be expired + for (int i = 0; i < K; i++) + { + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + } + + // Advance to let those K entries time out + clock.Advance(TimeSpan.FromMilliseconds(150)); + + // Register L active entries without advancing the global clock so they remain within the timeout window + for (int i = 0; i < L; i++) + { + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + // Do NOT advance the clock here: advancing would make early active entries expire before we report connections. + } + + // Act: report L times + for (int i = 0; i < L; i++) + { + sut.ReportConnection(mvid, "Wasm", isDebug: false); + } + + // Assert: at least K should have timed out, and we should have L connections in FIFO order + timeouts.Count.Should().BeGreaterOrEqualTo(K); + connections.Should().HaveCount(L); + for (int i = 1; i < connections.Count; i++) + { + connections[i - 1].RegisteredAt.Should().BeOnOrBefore(connections[i].RegisteredAt); + } + } + + [TestMethod] + public void WhenRegisteredLaunchTimeoutExpires_ThenTimeoutCallbackInvoked() + { + // Arrange + using var sut = CreateMonitor(out var clock, out _, out var timeouts, out _, TimeSpan.FromSeconds(10)); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // Act + clock.Advance(TimeSpan.FromSeconds(11)); + + // Assert + timeouts.Should().HaveCount(1); + } + + [TestMethod] + public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemoved() + { + // Arrange + using var sut = CreateMonitor(out var clock, out _, out var timeouts, out var connections); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // will expire + clock.Advance(TimeSpan.FromSeconds(5)); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // still active + + // Act + clock.Advance(TimeSpan.FromSeconds(6)); // first expired, second still active + + // Assert + timeouts.Should().HaveCount(1); + // Active one should still be connectable + sut.ReportConnection(mvid, "Wasm", isDebug: false); + connections.Should().HaveCount(1); + } + + [TestMethod] + public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentException() + { + using var sut = CreateMonitor(out var clock, out _, out _, out _); + var mvid = Guid.NewGuid(); + + sut.Invoking(m => m.RegisterLaunch(mvid, null!, true)).Should().Throw(); + sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true)).Should().Throw(); + sut.Invoking(m => m.ReportConnection(mvid, null!, true)).Should().Throw(); + sut.Invoking(m => m.ReportConnection(mvid, string.Empty, true)).Should().Throw(); + } + + [TestMethod] + public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() + { + using var sut = CreateMonitor(out var clock, out _, out _, out var connections); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", true); + clock.Advance(TimeSpan.FromSeconds(1)); // bellow timeout + + sut.ReportConnection(mvid, "wasm", true); + + connections.Should().BeEmpty(); + } + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index b657b7797dfa..99f34defa4a1 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -5,33 +5,43 @@ enable enable false + + net9.0 + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + - - - - - - - - + + diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs new file mode 100644 index 000000000000..b34e06d8db82 --- /dev/null +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Uno.UI.RemoteControl.Server.AppLaunch; + +/// +/// In-memory monitor for application launch events and connection matching. +/// - Stores launch signals and matches incoming connections to prior launches. +/// - Uses a value-type composite key (no string concatenations) to minimize allocations. +/// - Automatically handles timeouts using internal Task-based scheduling. +/// +public sealed class ApplicationLaunchMonitor : IDisposable +{ + /// + /// Options that control the behavior of . + /// + public class Options + { + /// + /// Timeout after which a registered launch is considered expired. Defaults to 60 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Callback invoked when an application is registered. + /// + public Action? OnRegistered { get; set; } + + /// + /// Callback invoked when a registered application timed out without connecting. + /// + public Action? OnTimeout { get; set; } + + /// + /// Callback invoked when a registered application successfully connected. + /// + public Action? OnConnected { get; set; } + } + + /// + /// Describes a single launch event recorded by the monitor. + /// + public sealed record LaunchEvent(Guid Mvid, string Platform, bool IsDebug, DateTimeOffset RegisteredAt); + + private readonly TimeProvider _timeProvider; + private readonly Options _options; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + // Non-allocating composite key (avoids string creation per lookup) + private readonly record struct Key(Guid Mvid, string Platform, bool IsDebug); + + private readonly ConcurrentDictionary> _pending = new(); + + // Track timeout tasks for each launch event + private readonly ConcurrentDictionary _timeoutTasks = new(); + + /// + /// Creates a new instance of . + /// + /// Optional time provider used for internal timing and for tests. If null, the system time provider is used. + /// Optional configuration for the monitor. If null, default options are used. + public ApplicationLaunchMonitor(TimeProvider? timeProvider = null, Options? options = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _options = options ?? new Options(); + } + + /// + /// Register that an application was launched. + /// Automatically starts the timeout countdown from the current time provider value. + /// Multiple registrations for the same key are kept and consumed in FIFO order. + /// + /// The MVID of the root/head application. + /// The platform used to run the application. Cannot be null or empty. + /// Whether the debugger is used. + public void RegisterLaunch(Guid mvid, string platform, bool isDebug) + { + if (string.IsNullOrEmpty(platform)) + throw new ArgumentException("platform cannot be null or empty", nameof(platform)); + + var now = _timeProvider.GetUtcNow(); + var ev = new LaunchEvent(mvid, platform, isDebug, now); + var key = new Key(mvid, platform, isDebug); + + var queue = _pending.GetOrAdd(key, _ => new ConcurrentQueue()); + queue.Enqueue(ev); + + // Schedule automatic timeout + ScheduleTimeout(ev, key); + + try + { + _options.OnRegistered?.Invoke(ev); + } + catch + { + // best-effort, swallow + } + } + + /// + /// Schedules a timeout task for the given launch event. + /// + /// The launch event to schedule timeout for. + /// The key for the launch event. + private void ScheduleTimeout(LaunchEvent launchEvent, Key key) + { + var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token); + _timeoutTasks[launchEvent] = timeoutCts; + + _ = TimeoutTask(); + + async Task TimeoutTask() + { + try + { + await Task + .Delay(_options.Timeout, _timeProvider, timeoutCts.Token) // use injected TimeProvider + .ConfigureAwait(false); // Ensure to continue on TimeProvider's calling context + + // Timeout occurred - handle it + HandleTimeout(launchEvent, key); + } + catch (OperationCanceledException) + { + // Timeout was cancelled (connection occurred or disposal) + } + finally + { + _timeoutTasks.TryRemove(launchEvent, out _); + timeoutCts.Dispose(); + } + } + } + + /// + /// Handles timeout for a specific launch event. + /// + /// The launch event that timed out. + /// The key for the launch event. + private void HandleTimeout(LaunchEvent launchEvent, Key key) + { + // Remove the timed out event from the pending queue + if (_pending.TryGetValue(key, out var queue)) + { + var tempQueue = new List(); + LaunchEvent? removedEvent = null; + + // Collect all items except the one that timed out + while (queue.TryDequeue(out var ev)) + { + if (ev.Equals(launchEvent) && removedEvent == null) + { + removedEvent = ev; + } + else + { + tempQueue.Add(ev); + } + } + + // Put back the non-timed-out events + foreach (var ev in tempQueue) + { + queue.Enqueue(ev); + } + + // If queue is empty, remove it + if (queue.IsEmpty) + { + _pending.TryRemove(key, out _); + } + + // Invoke timeout callback for the removed event + if (removedEvent != null) + { + try + { + _options.OnTimeout?.Invoke(removedEvent); + } + catch + { + // swallow + } + } + } + } + + /// + /// Reports an application successfully connecting back to development server. + /// If a matching registered launch exists, it consumes the oldest registration and the OnConnected callback is invoked for it. + /// Cancels the timeout task for the connected launch. + /// + /// The MVID of the root/head application being connected. + /// The name of the platform from which the connection is reported. Cannot be null or empty. + /// true if the connection is from a debug build; otherwise, false. + public void ReportConnection(Guid mvid, string platform, bool isDebug) + { + if (string.IsNullOrEmpty(platform)) + throw new ArgumentException("platform cannot be null or empty", nameof(platform)); + + var key = new Key(mvid, platform, isDebug); + if (_pending.TryGetValue(key, out var queue)) + { + if (queue.TryDequeue(out var ev)) + { + // Cancel the timeout task for this event + if (_timeoutTasks.TryRemove(ev, out var timeoutCts)) + { + timeoutCts.Cancel(); + timeoutCts.Dispose(); + } + + // If queue is now empty, remove it from dictionary + if (queue.IsEmpty) + { + _pending.TryRemove(key, out _); + } + + try + { + _options.OnConnected?.Invoke(ev); + } + catch + { + // swallow + } + } + } + } + + /// + /// Disposes of all resources used by the ApplicationLaunchMonitor. + /// Cancels all pending timeout tasks and clears all tracking data. + /// + public void Dispose() + { + // Cancel all pending timeout tasks + _cancellationTokenSource.Cancel(); + + // Clean up individual timeout tasks + foreach (var kvp in _timeoutTasks.ToArray()) + { + var timeoutCts = kvp.Value; + try + { + timeoutCts.Cancel(); + timeoutCts.Dispose(); + } + catch + { + // swallow + } + } + + _timeoutTasks.Clear(); + _pending.Clear(); + _cancellationTokenSource.Dispose(); + } +} diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md new file mode 100644 index 000000000000..c0cc44274a33 --- /dev/null +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md @@ -0,0 +1,49 @@ +# Application Launch Monitoring + +## What it is +- Small in-memory helper used by the Uno Remote Control Dev Server to correlate “I launched an app” with “that app connected back.” +- You tell it that an app was launched, then you report when a matching app connects. It matches them 1:1 in launch order and handles timeouts. +- When a launched application fails to connect, it is reported as a timeout thru the OnTimeout callback. +- It is thread-safe / Disposable. +- It used the MVID as the key. This is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid + +## How to use +### 1) Create the monitor (optionally with callbacks and a custom timeout): + +```csharp +using Uno.UI.RemoteControl.Server.AppLaunch; + +var options = new ApplicationLaunchMonitor.Options +{ + // Default is 60s + Timeout = TimeSpan.FromSeconds(30), + OnRegistered = ev => logger.LogInformation($"Launch registered: {ev.Mvid} ({ev.Platform})"), + OnConnected = ev => logger.LogInformation($"Connected: {ev.Mvid} ({ev.Platform})"), + OnTimeout = ev => logger.LogWarning($"Timed out: {ev.Mvid} ({ev.Platform})") +}; + +using var monitor = new ApplicationLaunchMonitor(options: options); +``` + +### 2) When you start a target app, register the launch: +```csharp +monitor.RegisterLaunch(mvid, "Wasm", isDebug: true); +``` + +### 3) When the app connects back to the dev server, report the connection: +```csharp +monitor.ReportConnection(mvid, "Wasm", isDebug: true); +``` + +That’s it. The monitor pairs the connection with the oldest pending launch for the same (mvid, platform, isDebug). If no connection arrives before the timeout, OnTimeout is invoked. + +## Notes +- Platform matching is case-sensitive ("Wasm" != "wasm"). +- Platform must not be null or empty (ArgumentException). +- Registrations are consumed in FIFO order per key. +- Always dispose the monitor (use "using" as shown). + +## Where it lives +- Project: Uno.UI.RemoteControl.Server +- Folder: AppLaunch +- File: `ApplicationLaunchMonitor.cs` (implementation) + this ApplicationLaunchMonitor.md (short guide) From 481c22cd16c0e9eb56039bc515a5473ea9b35578 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 12 Sep 2025 16:19:09 -0400 Subject: [PATCH 02/49] fix(remote-control): use TimeProvider timers and stabilize launch timeout behavior - Replace task-based timeout state machine with TimeProvider.CreateTimer timers\n- Store IDisposable timers and dispose on connection/dispose\n- Make RegisterLaunch/ReportConnection use ArgumentException.ThrowIfNullOrEmpty and static factory for queues\n- Update tests: deterministic time advancement and stress FIFO test\n- Update docs: add TimeProvider testing notes --- .../Given_ApplicationLaunchMonitor.cs | 11 ++- .../AppLaunch/ApplicationLaunchMonitor.cs | 84 ++++++++----------- .../AppLaunch/ApplicationLaunchMonitor.md | 12 +++ 3 files changed, 53 insertions(+), 54 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index 4e22180b0525..c4d404db79fe 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -113,31 +113,34 @@ public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stres var mvid = Guid.NewGuid(); // Register K entries which will be expired - for (int i = 0; i < K; i++) + for (var i = 0; i < K; i++) { sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromTicks(1)); } // Advance to let those K entries time out clock.Advance(TimeSpan.FromMilliseconds(150)); // Register L active entries without advancing the global clock so they remain within the timeout window - for (int i = 0; i < L; i++) + for (var i = 0; i < L; i++) { sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromTicks(1)); // Do NOT advance the clock here: advancing would make early active entries expire before we report connections. } // Act: report L times - for (int i = 0; i < L; i++) + for (var i = 0; i < L; i++) { sut.ReportConnection(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromTicks(1)); } // Assert: at least K should have timed out, and we should have L connections in FIFO order timeouts.Count.Should().BeGreaterOrEqualTo(K); connections.Should().HaveCount(L); - for (int i = 1; i < connections.Count; i++) + for (var i = 1; i < connections.Count; i++) { connections[i - 1].RegisteredAt.Should().BeOnOrBefore(connections[i].RegisteredAt); } diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index b34e06d8db82..47c5e41c1e9c 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -47,15 +47,14 @@ public sealed record LaunchEvent(Guid Mvid, string Platform, bool IsDebug, DateT private readonly TimeProvider _timeProvider; private readonly Options _options; - private readonly CancellationTokenSource _cancellationTokenSource = new(); // Non-allocating composite key (avoids string creation per lookup) private readonly record struct Key(Guid Mvid, string Platform, bool IsDebug); private readonly ConcurrentDictionary> _pending = new(); - // Track timeout tasks for each launch event - private readonly ConcurrentDictionary _timeoutTasks = new(); + // Track timeout timers for each launch event + private readonly ConcurrentDictionary _timeoutTasks = new(); /// /// Creates a new instance of . @@ -78,14 +77,13 @@ public ApplicationLaunchMonitor(TimeProvider? timeProvider = null, Options? opti /// Whether the debugger is used. public void RegisterLaunch(Guid mvid, string platform, bool isDebug) { - if (string.IsNullOrEmpty(platform)) - throw new ArgumentException("platform cannot be null or empty", nameof(platform)); + ArgumentException.ThrowIfNullOrEmpty(platform); var now = _timeProvider.GetUtcNow(); var ev = new LaunchEvent(mvid, platform, isDebug, now); var key = new Key(mvid, platform, isDebug); - var queue = _pending.GetOrAdd(key, _ => new ConcurrentQueue()); + var queue = _pending.GetOrAdd(key, static _ => new ConcurrentQueue()); queue.Enqueue(ev); // Schedule automatic timeout @@ -108,32 +106,32 @@ public void RegisterLaunch(Guid mvid, string platform, bool isDebug) /// The key for the launch event. private void ScheduleTimeout(LaunchEvent launchEvent, Key key) { - var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token); - _timeoutTasks[launchEvent] = timeoutCts; + // Create a one-shot timer using the injected TimeProvider. When it fires, it will invoke HandleTimeout. + var timer = _timeProvider.CreateTimer( + static s => + { + var (self, ev, k) = ((ApplicationLaunchMonitor, LaunchEvent, Key))s!; - _ = TimeoutTask(); + // Remove and dispose the timer entry if still present + if (self._timeoutTasks.TryRemove(ev, out var t)) + { + try { t.Dispose(); } catch { } + } - async Task TimeoutTask() - { - try - { - await Task - .Delay(_options.Timeout, _timeProvider, timeoutCts.Token) // use injected TimeProvider - .ConfigureAwait(false); // Ensure to continue on TimeProvider's calling context + try + { + self.HandleTimeout(ev, k); + } + catch + { + // swallow + } + }, + (this, launchEvent, key), + _options.Timeout, + Timeout.InfiniteTimeSpan); - // Timeout occurred - handle it - HandleTimeout(launchEvent, key); - } - catch (OperationCanceledException) - { - // Timeout was cancelled (connection occurred or disposal) - } - finally - { - _timeoutTasks.TryRemove(launchEvent, out _); - timeoutCts.Dispose(); - } - } + _timeoutTasks[launchEvent] = timer; } /// @@ -199,19 +197,17 @@ private void HandleTimeout(LaunchEvent launchEvent, Key key) /// true if the connection is from a debug build; otherwise, false. public void ReportConnection(Guid mvid, string platform, bool isDebug) { - if (string.IsNullOrEmpty(platform)) - throw new ArgumentException("platform cannot be null or empty", nameof(platform)); + ArgumentException.ThrowIfNullOrEmpty(platform); var key = new Key(mvid, platform, isDebug); if (_pending.TryGetValue(key, out var queue)) { if (queue.TryDequeue(out var ev)) { - // Cancel the timeout task for this event - if (_timeoutTasks.TryRemove(ev, out var timeoutCts)) + // Cancel / dispose the timeout timer for this event + if (_timeoutTasks.TryRemove(ev, out var timeoutTimer)) { - timeoutCts.Cancel(); - timeoutCts.Dispose(); + try { timeoutTimer.Dispose(); } catch { } } // If queue is now empty, remove it from dictionary @@ -238,26 +234,14 @@ public void ReportConnection(Guid mvid, string platform, bool isDebug) /// public void Dispose() { - // Cancel all pending timeout tasks - _cancellationTokenSource.Cancel(); - - // Clean up individual timeout tasks + // Dispose all timers and clear pending timeout tasks foreach (var kvp in _timeoutTasks.ToArray()) { - var timeoutCts = kvp.Value; - try - { - timeoutCts.Cancel(); - timeoutCts.Dispose(); - } - catch - { - // swallow - } + var timer = kvp.Value; + try { timer.Dispose(); } catch { } } _timeoutTasks.Clear(); _pending.Clear(); - _cancellationTokenSource.Dispose(); } } diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md index c0cc44274a33..c60b34d1a8ef 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md @@ -6,6 +6,7 @@ - When a launched application fails to connect, it is reported as a timeout thru the OnTimeout callback. - It is thread-safe / Disposable. - It used the MVID as the key. This is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid +- It uses the MVID as the key. This is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid ## How to use ### 1) Create the monitor (optionally with callbacks and a custom timeout): @@ -43,6 +44,17 @@ That’s it. The monitor pairs the connection with the oldest pending launch for - Registrations are consumed in FIFO order per key. - Always dispose the monitor (use "using" as shown). +### Testing / Time control + +- The constructor accepts an optional `TimeProvider` which the monitor uses for timeout scheduling. Tests commonly inject `Microsoft.Extensions.Time.Testing.FakeTimeProvider` and advance time with `fake.Advance(...)` to trigger timeouts instantly. +- Example for tests: + +```csharp +var fake = new FakeTimeProvider(DateTimeOffset.UtcNow); +using var monitor = new ApplicationLaunchMonitor(timeProvider: fake, options: options); +// register, then fake.Advance(TimeSpan.FromSeconds(11)); // triggers timeout callbacks +``` + ## Where it lives - Project: Uno.UI.RemoteControl.Server - Folder: AppLaunch From 76abdaf225477047b0ebb58ce767be7157a9caf7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 2 Oct 2025 09:13:23 -0400 Subject: [PATCH 03/49] refactor(remote-control): Simplify `ServerProcessor` attribute usage with generic type support --- .../IServerProcessor.cs | 16 +++++++--------- .../HotReload/FileUpdateProcessor.cs | 3 ++- .../HotReload/ServerHotReloadProcessor.cs | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Messaging/IServerProcessor.cs b/src/Uno.UI.RemoteControl.Messaging/IServerProcessor.cs index 9173d13fd8a2..642ebbc61376 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IServerProcessor.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IServerProcessor.cs @@ -24,15 +24,13 @@ public interface IServerProcessor : IDisposable Task ProcessIdeMessage(IdeMessage message, CancellationToken ct); } - [System.AttributeUsage(AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] - public sealed class ServerProcessorAttribute : Attribute + [AttributeUsage(AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + public class ServerProcessorAttribute(Type processor) : Attribute { - readonly Type processor; - - // This is a positional argument - public ServerProcessorAttribute(Type processor) => this.processor = processor; - - public Type ProcessorType - => processor; + public Type ProcessorType => processor; } + + [AttributeUsage(AttributeTargets.Assembly, Inherited = false, AllowMultiple = true)] + public sealed class ServerProcessorAttribute() : ServerProcessorAttribute(typeof(T)) + where T : IServerProcessor; } diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs index 91efc3640555..0618563f5486 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs @@ -5,10 +5,11 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Uno.Extensions; +using Uno.UI.RemoteControl.Host.HotReload; using Uno.UI.RemoteControl.HotReload.Messages; using Uno.UI.RemoteControl.Messaging.IdeChannel; -[assembly: Uno.UI.RemoteControl.Host.ServerProcessorAttribute(typeof(Uno.UI.RemoteControl.Host.HotReload.FileUpdateProcessor))] +[assembly: Uno.UI.RemoteControl.Host.ServerProcessor] namespace Uno.UI.RemoteControl.Host.HotReload; diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index b553883e864f..247ebf41bbae 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -20,7 +20,7 @@ using Uno.UI.RemoteControl.Messaging.IdeChannel; using Uno.UI.RemoteControl.Server.Telemetry; -[assembly: Uno.UI.RemoteControl.Host.ServerProcessorAttribute(typeof(Uno.UI.RemoteControl.Host.HotReload.ServerHotReloadProcessor))] +[assembly: Uno.UI.RemoteControl.Host.ServerProcessor] namespace Uno.UI.RemoteControl.Host.HotReload { From 7e00f518912980786a4ab1b06d5d4510bce6d0ae Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 2 Oct 2025 09:16:03 -0400 Subject: [PATCH 04/49] chore: Improve uni test assembly discovery helpers --- .../Helpers/DevServerTestHelper.cs | 71 ++++++------------- .../Telemetry/TelemetryTestBase.cs | 39 ++++++++-- src/Uno.UI.RemoteControl.Host/Program.cs | 7 +- .../RemoteControlServer.cs | 5 +- .../HotReload/ClientHotReloadProcessor.cs | 2 +- 5 files changed, 64 insertions(+), 60 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs index 87783d031d23..74e3a0f6aa90 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs @@ -1,5 +1,9 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.IO.Pipes; +using System.Reflection; +using System.Text.RegularExpressions; +using StreamJsonRpc; namespace Uno.UI.RemoteControl.DevServer.Tests.Helpers; @@ -287,63 +291,30 @@ public void AssertErrorOutputContains(string text) /// Discovers the path to the Host DLL using multiple strategies. /// /// The path to the Host DLL if found, null otherwise. - private string? DiscoverHostDllPath() + private string? GetCurrentBuildConfiguration() { - // Strategy 1: Check environment variable for custom path - var customPath = Environment.GetEnvironmentVariable("UNO_DEVSERVER_HOST_DLL_PATH"); - if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) - { - _logger.LogInformation("Using custom Host DLL path from environment variable: {Path}", customPath); - return customPath; - } - - // Strategy 2: Try different build configurations and frameworks - var assemblyLocation = Path.GetDirectoryName(typeof(DevServerTestHelper).Assembly.Location)!; - var configurations = new[] { "Debug", "Release" }; - var frameworks = new[] { "net10.0", "net9.0", }; - - foreach (var config in configurations) + try { - foreach (var framework in frameworks) - { - var hostDllPath = Path.GetFullPath(Path.Combine( - assemblyLocation, - "..", "..", "..", "..", - "Uno.UI.RemoteControl.Host", "bin", config, framework, - "Uno.UI.RemoteControl.Host.dll")); - - if (File.Exists(hostDllPath)) - { - _logger.LogInformation("Found Host DLL using discovery: {Path}", hostDllPath); - return hostDllPath; - } - } + return (typeof(DevServerTestHelper) + .Assembly.GetCustomAttribute(typeof(AssemblyConfigurationAttribute)) as AssemblyConfigurationAttribute) + ?.Configuration; } - - // Strategy 3: Try MSBuild output directory patterns - var possiblePaths = new[] - { - Path.Combine(assemblyLocation, "Uno.UI.RemoteControl.Host.dll"), - Path.Combine(assemblyLocation, "..", "Uno.UI.RemoteControl.Host", "Uno.UI.RemoteControl.Host.dll"), - }; - - foreach (var path in possiblePaths) + catch (Exception ex) { - var fullPath = Path.GetFullPath(path); - if (File.Exists(fullPath)) - { - _logger.LogInformation("Found Host DLL in output directory: {Path}", fullPath); - return fullPath; - } + _logger.LogWarning(ex, "Failed to get current build configuration"); + return null; } - - _logger.LogError( - "Could not discover Host DLL path. Tried configurations: {Configurations}, frameworks: {Frameworks}", - string.Join(", ", configurations), string.Join(", ", frameworks)); - return null; } - /// + private string? DiscoverHostDllPath() => + ExternalDllDiscoveryHelper.DiscoverExternalDllPath( + _logger, + typeof(DevServerTestHelper).Assembly, + projectName: "Uno.UI.RemoteControl.Host", + dllFileName: "Uno.UI.RemoteControl.Host.dll", + environmentVariableName: "UNO_DEVSERVER_HOST_DLL_PATH"); + + /// /// Determines if the server has started based on console output. /// Uses multiple indicators to be more robust than just "Application started". /// diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index 5359dc719e86..55f1229f071f 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -3,6 +3,7 @@ using Windows.ApplicationModel; using Microsoft.UI.Xaml; using Uno.UI.RemoteControl.DevServer.Tests.Helpers; +using System.Threading; namespace Uno.UI.RemoteControl.DevServer.Tests.Telemetry; @@ -12,7 +13,25 @@ public abstract class TelemetryTestBase public TestContext? TestContext { get; set; } - protected CancellationToken CT => TestContext?.CancellationTokenSource.Token ?? CancellationToken.None; + private CancellationToken GetTimeoutToken() + { + var baseToken = TestContext?.CancellationTokenSource.Token ?? CancellationToken.None; + if (!Debugger.IsAttached) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(baseToken); +#if DEBUG + // 45 seconds when running locally (DEBUG) without debugger + cts.CancelAfter(TimeSpan.FromMinutes(.75)); +#else + // 2 minutes on CI + cts.CancelAfter(TimeSpan.FromMinutes(2)); +#endif + return cts.Token; + } + return baseToken; + } + + protected CancellationToken CT => GetTimeoutToken(); protected SolutionHelper? SolutionHelper { get; private set; } @@ -75,15 +94,23 @@ private static void InitializeLogger(Type type) /// /// Creates a DevServerTestHelper with telemetry redirection to an exact file path. /// - protected DevServerTestHelper CreateTelemetryHelperWithExactPath(string exactFilePath, string? solutionPath = null) + protected DevServerTestHelper CreateTelemetryHelperWithExactPath(string exactFilePath, string? solutionPath = null, bool enableNamedPipe = false) { + var envVars = new Dictionary + { + { "UNO_PLATFORM_TELEMETRY_FILE", exactFilePath } + }; + + if (enableNamedPipe) + { + // Create an IDE channel GUID so the dev-server will initialize the named-pipe IDE channel + envVars["ideChannel"] = Guid.NewGuid().ToString(); + } + return new DevServerTestHelper( Logger, solutionPath: solutionPath, - environmentVariables: new Dictionary - { - { "UNO_PLATFORM_TELEMETRY_FILE", exactFilePath }, - }); + environmentVariables: envVars); } /// diff --git a/src/Uno.UI.RemoteControl.Host/Program.cs b/src/Uno.UI.RemoteControl.Host/Program.cs index fac5d0887945..9d88485440e0 100644 --- a/src/Uno.UI.RemoteControl.Host/Program.cs +++ b/src/Uno.UI.RemoteControl.Host/Program.cs @@ -212,7 +212,9 @@ static async Task Main(string[] args) _ = host.Services.GetRequiredService().StartAsync(ct.Token); // Background services are not supported by WebHostBuilder // Display DevServer version banner - DisplayVersionBanner(httpPort); + var config = host.Services.GetRequiredService(); + var ideChannelId = config["ideChannel"]; // GUID used as the named pipe name when IDE channel is enabled + DisplayVersionBanner(httpPort, ideChannelId); // STEP 3: Use global telemetry for server-wide events // Track devserver startup using global telemetry service @@ -283,7 +285,7 @@ static async Task Main(string[] args) /// /// Displays a banner with the DevServer version information when it starts up. /// - private static void DisplayVersionBanner(int httpPort) + private static void DisplayVersionBanner(int httpPort, string? ideChannelId) { try { @@ -307,6 +309,7 @@ private static void DisplayVersionBanner(int httpPort) ("Assembly", assemblyName), ("Location", Path.GetDirectoryName(location) ?? location, Helpers.BannerHelper.ClipMode.Start), ("HTTP Port", httpPort.ToString(DateTimeFormatInfo.InvariantInfo)), + ("IDE Channel", string.IsNullOrWhiteSpace(ideChannelId) ? "Disabled" : $@"\\.\pipe\{ideChannelId}"), }; Helpers.BannerHelper.Write("Uno Platform DevServer", entries); diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index fc7c39126968..3b289d04ffaa 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -19,6 +19,7 @@ using Uno.UI.RemoteControl.HotReload.Messages; using Uno.UI.RemoteControl.Messages; using Uno.UI.RemoteControl.Messaging.IdeChannel; +using Uno.UI.RemoteControl.Server.AppLaunch; using Uno.UI.RemoteControl.Server.Helpers; using Uno.UI.RemoteControl.Server.Telemetry; using Uno.UI.RemoteControl.Services; @@ -230,7 +231,9 @@ public async Task RunAsync(WebSocket socket, CancellationToken ct) { if (this.Log().IsEnabled(LogLevel.Debug)) { - this.Log().LogDebug("Unknown Frame [{Scope} / {Name}]", frame.Scope, frame.Name); + // Log no processors found to process this frame (give a registered processors list) + var processorsList = string.Join(", ", _processors.Keys); + this.Log().LogDebug("Unknown Frame [{Scope} / {Name}] - No processors registered. Registered processors: {processorsList}", frame.Scope, frame.Name, processorsList); } } } diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs index c907e23f85b8..53728507ef07 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.cs @@ -53,7 +53,7 @@ public async Task ProcessFrame(Messages.Frame frame) default: if (this.Log().IsEnabled(LogLevel.Error)) { - this.Log().LogError($"Unknown frame [{frame.Scope}/{frame.Name}]"); + this.Log().LogError($"Received unknown frame [{frame.Scope}/{frame.Name}]"); } break; } From d967317a01199472e830e97911c1cec41e74d92b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 2 Oct 2025 23:05:05 -0400 Subject: [PATCH 05/49] feat(app-launch): Add support for tracking application launches and runtime connections in dev server - Introduced `ApplicationLaunchMonitor` for correlating app launches with runtime connections. - Extended `RemoteControlClient` with app identity registration and server processors discovery options. - Implemented new HTTP endpoint and IDE channel message handling for app launch registration. --- .../AppLaunch/AppLaunchIntegrationTests.cs | 164 ++++++++++++++++ .../Helpers/DevServerTestHelper.cs | 104 +++++++++- .../Helpers/ExternalDllDiscoveryHelper.cs | 180 +++++++++++++++++ .../Telemetry/TelemetryTestBase.cs | 6 +- src/Uno.UI.RemoteControl.Host/Program.cs | 9 + .../RemoteControlExtensions.cs | 44 +++++ .../RemoteControlServer.cs | 70 +++++-- .../IDEChannel/AppLaunchRegisterIdeMessage.cs | 11 ++ .../Helpers/ServiceCollectionExtensions.cs | 38 ++++ .../Helpers/ApplicationInfoHelper.cs | 21 ++ .../Messages/AppLaunchMessage.cs | 30 +++ .../RemoteControlClient.cs | 185 ++++++++++++++++-- 12 files changed, 835 insertions(+), 27 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs create mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/ExternalDllDiscoveryHelper.cs create mode 100644 src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs create mode 100644 src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs create mode 100644 src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs new file mode 100644 index 000000000000..0d7374e9ffa8 --- /dev/null +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Linq; +using System.IO; +using System.Net.Http; +using Uno.UI.RemoteControl.DevServer.Tests.Telemetry; +using Uno.UI.RemoteControl.DevServer.Tests.Helpers; +using Uno.UI.RemoteControl.Messaging.IdeChannel; +using Uno.UI.RemoteControl.Messages; +using FluentAssertions; +using Uno.UI.RemoteControl.Helpers; + +namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; + +[TestClass] +public class AppLaunchIntegrationTests : TelemetryTestBase +{ + private static readonly string? _serverProcessorAssembly = ExternalDllDiscoveryHelper.DiscoverExternalDllPath( + Logger, + typeof(DevServerTestHelper).Assembly, + projectName: "Uno.UI.RemoteControl.Server.Processors", + dllFileName: "Uno.UI.RemoteControl.Server.Processors.dll"); + + [ClassInitialize] + public static void ClassInitialize(TestContext context) => GlobalClassInitialize(context); + + [TestMethod] + public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() + { + // PRE-ARRANGE: Create a solution file + var solution = SolutionHelper!; + await solution.CreateSolutionFile(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success")); + await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); + + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + var asm = typeof(AppLaunchIntegrationTests).Assembly; + var mvid = ApplicationInfoHelper.GetMvid(asm); + var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var isDebug = Debugger.IsAttached; + + // ACT - STEP 1: Register app launch via HTTP GET (simulating IDE -> dev server) + using (var http = new HttpClient()) + { + var url = $"http://localhost:{helper.Port}/applaunch/{mvid}?platform={Uri.EscapeDataString(platform)}&isDebug={isDebug.ToString().ToLowerInvariant()}"; + var response = await http.GetAsync(url, CT); + response.EnsureSuccessStatusCode(); + } + + // ACT - STEP 2: Connect from application (simulating app -> dev server) + var rcClient = RemoteControlClient.Initialize( + typeof(AppLaunchIntegrationTests), + [new ServerEndpointAttribute("localhost", helper.Port)], + _serverProcessorAssembly, + autoRegisterAppIdentity: true); + + await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10)); + + // ACT - STEP 3: Stop and gather telemetry events + await Task.Delay(1500, CT); + await helper.AttemptGracefulShutdown(CT); + + // ASSERT + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue(); + events.Should().NotBeEmpty(); + AssertHasEvent(events, "uno/dev-server/app-launch/launched"); + AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); + } + finally + { + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + + [TestMethod] + public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeChannel() + { + // PRE-ARRANGE: Create a solution file + var solution = SolutionHelper!; + await solution.CreateSolutionFile(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success_idechannel")); + await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: true); + + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + var asm = typeof(AppLaunchIntegrationTests).Assembly; + var mvid = ApplicationInfoHelper.GetMvid(asm); + var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var isDebug = Debugger.IsAttached; + + // ACT - STEP 1: Register app launch via IDE channel (IDE -> dev server) + using (var ide = helper.CreateIdeChannelClient()) + { + await ide.EnsureConnectedAsync(CT); + await Task.Delay(1500, CT); + await ide.SendToDevServerAsync(new AppLaunchRegisterIdeMessage(mvid, platform, isDebug), CT); + } + + // ACT - STEP 2: Connect from application (app -> dev server) + var rcClient = RemoteControlClient.Initialize( + typeof(AppLaunchIntegrationTests), + [new ServerEndpointAttribute("localhost", helper.Port)], + _serverProcessorAssembly, + autoRegisterAppIdentity: true); + + await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10)); + + // ACT - STEP 3: Stop and gather telemetry events + await Task.Delay(1500, CT); + await helper.AttemptGracefulShutdown(CT); + + // ASSERT + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue(); + events.Should().NotBeEmpty(); + AssertHasEvent(events, "uno/dev-server/app-launch/launched"); + AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); + } + finally + { + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + + // [TestMethod] + // public async Task WhenRegisteredAndNoRuntimeConnects_TimeoutEventEmitted() + // { + // throw new NotImplementedException("TODO"); + // } + + private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path) + => File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : []; + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) { try { File.Delete(path); } catch { } } + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs index 74e3a0f6aa90..17fa1d4ce3ad 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs @@ -55,11 +55,33 @@ public sealed class DevServerTestHelper : IAsyncDisposable /// public bool IsRunning => _devServerProcess != null && !_devServerProcess.HasExited; + /// + /// Gets the IDE channel ID if available from environment variables. + /// + public Guid? IdeChannelId + { + get + { + if (_environmentVariables?.TryGetValue("UNO_PLATFORM_DEVSERVER_ideChannel", out var v) is true) + { + return Guid.TryParse(v, out var g) ? g : null; + } + + return null; + } + } + /// /// Gets the HTTP port that the dev server is using. /// public int Port => _httpPort; + /// + /// Creates a simple test IDE channel client that can send IDE messages to the running dev-server. + /// This is a lightweight helper used by integration tests. + /// + public TestIdeClient CreateIdeChannelClient() => new(this); + /// /// Initializes a new instance of the class. /// @@ -123,7 +145,6 @@ public async Task StartAsync(CancellationToken ct, int timeout = 30000, st WorkingDirectory = Path.GetDirectoryName(hostDllPath) // Set working directory to ensure all dependencies are found }; - // Merge environment variables more safely if (_environmentVariables != null) { foreach (var variable in _environmentVariables) @@ -497,3 +518,84 @@ public void EnsureStarted() public int? GetProcessId() => _devServerProcess?.Id; } + +/// +/// Lightweight IDE channel client for tests. +/// Uses HTTP endpoint of the dev-server to post IDE messages over the IDE channel if available. +/// For the purposes of these integration tests we only implement SendToDevServerAsync used by tests. +/// +public sealed class TestIdeClient : IDisposable +{ + private readonly DevServerTestHelper _helper; + + private NamedPipeClientStream? _pipeClient; + private JsonRpc? _rpcClient; + + internal TestIdeClient(DevServerTestHelper helper) + { + _helper = helper; + } + + public void Dispose() + { + try + { + _rpcClient?.Dispose(); + } + catch { } + try + { + _pipeClient?.Dispose(); + } + catch { } + } + + + public async Task EnsureConnectedAsync(CancellationToken ct) + { + if (_rpcClient != null) + { + return; + } + + var ideChannel = _helper.IdeChannelId?.ToString(); + + if (string.IsNullOrWhiteSpace(ideChannel)) + { + return; + } + + _pipeClient = new NamedPipeClientStream( + serverName: ".", + pipeName: ideChannel, + direction: PipeDirection.InOut, + options: PipeOptions.Asynchronous | PipeOptions.WriteThrough); + + await _pipeClient.ConnectAsync(ct); + + // Attach a lightweight proxy to invoke SendToDevServerAsync on the server + _rpcClient = JsonRpc.Attach(_pipeClient); + } + + public async Task SendToDevServerAsync(Uno.UI.RemoteControl.Messaging.IdeChannel.IdeMessage envelope, CancellationToken ct) + { + try + { + await EnsureConnectedAsync(ct); + if (_rpcClient is null) + { + // not connected + return; + } + + // IIdeChannelServer.SendToDevServerAsync accepts an IdeMessageEnvelope and a CancellationToken + // Call the method by name using JsonRpc + await _rpcClient.InvokeWithParameterObjectAsync("SendToDevServerAsync", new object[] { envelope, ct }); + } + catch (Exception ex) + { + // Best-effort logging into helper output + try { _helper?.ConsoleOutput.Contains(ex.Message); } catch { } + } + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/ExternalDllDiscoveryHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/ExternalDllDiscoveryHelper.cs new file mode 100644 index 000000000000..3e464ce8538d --- /dev/null +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/ExternalDllDiscoveryHelper.cs @@ -0,0 +1,180 @@ +using System.Reflection; +using System.IO; +using System.Text.RegularExpressions; + +namespace Uno.UI.RemoteControl.DevServer.Tests.Helpers; + +/// +/// Provides utilities to discover the path of external DLLs built within the repository. +/// This helper encapsulates the file system probing logic so it can be reused by tests +/// that need to locate various host/process binaries. +/// +internal static class ExternalDllDiscoveryHelper +{ + /// + /// High-level discovery that derives configuration/framework from the provided assembly and + /// optionally honors an environment variable override. + /// + /// Logger used for diagnostics. + /// Assembly used to infer current config and target framework. + /// The project folder name in the repo (e.g., "Uno.UI.RemoteControl.Host"). + /// The DLL file name to locate (e.g., "Uno.UI.RemoteControl.Host.dll"). + /// Optional environment variable name that, when set to a valid file path, overrides discovery. + /// Optional additional configurations to probe. + /// Optional additional target frameworks to probe. + /// The full path to the DLL if found; otherwise null. + public static string? DiscoverExternalDllPath( + ILogger logger, + Assembly contextAssembly, + string projectName, + string dllFileName, + string? environmentVariableName = null, + IReadOnlyList? additionalConfigurations = null, + IReadOnlyList? additionalTargetFrameworks = null) + { + // Strategy 0: Optional environment variable override + if (!string.IsNullOrWhiteSpace(environmentVariableName)) + { + var customPath = Environment.GetEnvironmentVariable(environmentVariableName); + if (!string.IsNullOrEmpty(customPath) && File.Exists(customPath)) + { + logger.LogInformation("Using custom DLL path from environment variable {EnvVar}: {Path}", environmentVariableName, customPath); + return customPath; + } + } + + var assemblyLocation = Path.GetDirectoryName(contextAssembly.Location)!; + + // Build configurations list: current -> Debug -> Release (+ any additions) + var configurations = new List(capacity: 4); + try + { + var cfg = (contextAssembly.GetCustomAttribute(typeof(AssemblyConfigurationAttribute)) as AssemblyConfigurationAttribute)?.Configuration; + if (!string.IsNullOrWhiteSpace(cfg)) + { + configurations.Add(cfg!); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to get current build configuration"); + } + configurations.Add("Debug"); + configurations.Add("Release"); + if (additionalConfigurations is not null) + { + foreach (var c in additionalConfigurations) + { + if (!string.IsNullOrWhiteSpace(c)) + { + configurations.Add(c); + } + } + } + // De-dup while preserving order + var uniqueConfigurations = configurations.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + // Determine compatible target frameworks from contextAssembly +#pragma warning disable SYSLIB1045 + var currentFramework = contextAssembly.GetCustomAttributes(true) + .OfType() + .FirstOrDefault()?.FrameworkName ?? string.Empty; + var versionMatch = Regex.Match(currentFramework, @"Version=v(\d+\.\d+)"); + var frameworks = new List(); + if (versionMatch.Success && Version.TryParse(versionMatch.Groups[1].Value, out var currentVersion)) + { + frameworks.Add($"net{currentVersion}"); + frameworks.Add($"net{currentVersion.Major + 1}.0"); + } + else + { + frameworks.Add("net10.0"); + } +#pragma warning restore SYSLIB1045 + if (additionalTargetFrameworks is not null) + { + foreach (var tfm in additionalTargetFrameworks) + { + if (!string.IsNullOrWhiteSpace(tfm)) + { + frameworks.Add(tfm); + } + } + } + var uniqueFrameworks = frameworks.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + var discovered = DiscoverExternalDllPath( + logger, + assemblyLocation, + uniqueConfigurations, + uniqueFrameworks, + projectName, + dllFileName); + + if (discovered is null) + { + logger.LogError( + "Could not discover DLL path. Tried configurations: {Configurations}, frameworks: {Frameworks} for {Project}/{Dll}", + string.Join(", ", uniqueConfigurations), string.Join(", ", uniqueFrameworks), projectName, dllFileName); + } + + return discovered; + } + + /// + /// Discovers a DLL path by probing common build output locations relative to an assembly location. + /// + /// Logger used for diagnostics. + /// Typically the current test assembly directory. + /// Configurations to probe (e.g., Debug/Release). + /// Target frameworks to probe (e.g., net9.0, net10.0). + /// The project folder name in the repo (e.g., "Uno.UI.RemoteControl.Host"). + /// The DLL file name to locate (e.g., "Uno.UI.RemoteControl.Host.dll"). + /// The full path to the DLL if found; otherwise null. + public static string? DiscoverExternalDllPath( + Microsoft.Extensions.Logging.ILogger logger, + string assemblyLocation, + IReadOnlyList configurations, + IReadOnlyList frameworks, + string projectName, + string dllFileName) + { + // Strategy A: Probe repository build output folders + foreach (var config in configurations) + { + foreach (var framework in frameworks) + { + var candidate = Path.GetFullPath(Path.Combine( + assemblyLocation, + "..", "..", "..", "..", + projectName, "bin", config, framework, + dllFileName)); + + if (File.Exists(candidate)) + { + logger.LogInformation("Found DLL using discovery: {Path}", candidate); + return candidate; + } + } + } + + // Strategy B: Try MSBuild output directory patterns next to test assembly + var possiblePaths = new[] + { + Path.Combine(assemblyLocation, dllFileName), + Path.Combine(assemblyLocation, "..", projectName, dllFileName), + }; + + foreach (var path in possiblePaths) + { + var fullPath = Path.GetFullPath(path); + if (File.Exists(fullPath)) + { + logger.LogInformation("Found DLL in output directory: {Path}", fullPath); + return fullPath; + } + } + + return null; + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index 55f1229f071f..631d9eba74ab 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -94,17 +94,17 @@ private static void InitializeLogger(Type type) /// /// Creates a DevServerTestHelper with telemetry redirection to an exact file path. /// - protected DevServerTestHelper CreateTelemetryHelperWithExactPath(string exactFilePath, string? solutionPath = null, bool enableNamedPipe = false) + protected DevServerTestHelper CreateTelemetryHelperWithExactPath(string exactFilePath, string? solutionPath = null, bool enableIdeChannel = false) { var envVars = new Dictionary { { "UNO_PLATFORM_TELEMETRY_FILE", exactFilePath } }; - if (enableNamedPipe) + if (enableIdeChannel) { // Create an IDE channel GUID so the dev-server will initialize the named-pipe IDE channel - envVars["ideChannel"] = Guid.NewGuid().ToString(); + envVars["UNO_PLATFORM_DEVSERVER_ideChannel"] = Guid.NewGuid().ToString(); } return new DevServerTestHelper( diff --git a/src/Uno.UI.RemoteControl.Host/Program.cs b/src/Uno.UI.RemoteControl.Host/Program.cs index 9d88485440e0..0092f440e1c6 100644 --- a/src/Uno.UI.RemoteControl.Host/Program.cs +++ b/src/Uno.UI.RemoteControl.Host/Program.cs @@ -23,6 +23,7 @@ using Uno.UI.RemoteControl.Server.Telemetry; using Uno.UI.RemoteControl.Services; using Uno.UI.RemoteControl.Helpers; +using Uno.UI.RemoteControl.Server.AppLaunch; namespace Uno.UI.RemoteControl.Host { @@ -176,12 +177,16 @@ static async Task Main(string[] args) .ConfigureAppConfiguration((hostingContext, config) => { config.AddCommandLine(args); + config.AddEnvironmentVariables("UNO_PLATFORM_DEVSERVER_"); }) .ConfigureServices(services => { services.AddSingleton(_ => globalServiceProvider.GetRequiredService()); services.AddSingleton(); + services.AddSingleton( + _ => globalServiceProvider.GetRequiredService()); + // Add the global service provider to the DI container services.AddKeyedSingleton("global", globalServiceProvider); @@ -298,11 +303,15 @@ private static void DisplayVersionBanner(int httpPort, string? ideChannelId) var runtimeText = targetFrameworkAttr is not null ? $"dotnet v{Environment.Version} (Assembly target: {targetFrameworkAttr.FrameworkDisplayName})" : $"dotnet v{Environment.Version}"; +#if DEBUG + var lastWriteTime = File.GetLastWriteTime(location); +#endif var entries = new List() { #if DEBUG ("Build", "DEBUG"), + ("Build Date/Time", $"{lastWriteTime:yyyy-MM-dd/HH:mm:ss} ({DateTime.Now - lastWriteTime:g} ago)"), #endif ("Version", version), ("Runtime", runtimeText), diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 946fc631baa3..98257778113f 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -12,6 +12,9 @@ using Uno.UI.RemoteControl.Server.Helpers; using Uno.UI.RemoteControl.Services; using Uno.UI.RemoteControl.Server.Telemetry; +using Uno.UI.RemoteControl.Server.AppLaunch; +using Microsoft.AspNetCore.Http; +using System.Text.Json; namespace Uno.UI.RemoteControl.Host { @@ -110,6 +113,47 @@ public static IApplicationBuilder UseRemoteControlServer( } } }); + + // HTTP GET endpoint to register an app launch + router.MapGet("applaunch/{mvid}", async context => + { + var mvidValue = context.GetRouteValue("mvid")?.ToString(); + if (!Guid.TryParse(mvidValue, out var mvid)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Invalid MVID - must be a valid GUID."); + return; + } + + string? platform = null; + var isDebug = false; + + // Query string support: ?platform=...&isDebug=true + if (context.Request.Query.TryGetValue("platform", out var p)) + { + platform = p.ToString(); + } + if (context.Request.Query.TryGetValue("isDebug", out var d)) + { + if (bool.TryParse(d.ToString(), out var qParsed)) + { + isDebug = qParsed; + } + } + + if (string.IsNullOrWhiteSpace(platform)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing required 'platform'."); + return; + } + + var monitor = context.RequestServices.GetRequiredService(); + monitor.RegisterLaunch(mvid, platform!, isDebug); + + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("registered"); + }); }); return app; diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index 3b289d04ffaa..c6c2d5ea3fd2 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -192,18 +192,19 @@ public async Task RunAsync(WebSocket socket, CancellationToken ct) { try { - if (frame.Scope == "RemoteControlServer") + if (frame.Scope == WellKnownScopes.DevServerChannel) { - if (frame.Name == ProcessorsDiscovery.Name) + switch (frame.Name) { - await ProcessDiscoveryFrame(frame); - continue; - } - - if (frame.Name == KeepAliveMessage.Name) - { - await ProcessPingFrame(frame); - continue; + case ProcessorsDiscovery.Name: + await ProcessDiscoveryFrame(frame); + continue; + case KeepAliveMessage.Name: + await ProcessPingFrame(frame); + continue; + case AppLaunchMessage.Name: + await ProcessAppLaunchFrame(frame); + continue; } } @@ -249,7 +250,18 @@ public async Task RunAsync(WebSocket socket, CancellationToken ct) private void ProcessIdeMessage(object? sender, IdeMessage message) { - if (_processors.TryGetValue(message.Scope, out var processor)) + Console.WriteLine($"Received message from IDE: {message.GetType().Name}"); + if (message is AppLaunchRegisterIdeMessage appLaunchRegisterIdeMessage) + { + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().LogDebug("Received app launch register message from IDE, mvid={Mvid}, platform={Platform}, isDebug={IsDebug}", appLaunchRegisterIdeMessage.Mvid, appLaunchRegisterIdeMessage.Platform, appLaunchRegisterIdeMessage.IsDebug); + } + var monitor = _serviceProvider.GetRequiredService(); + + monitor.RegisterLaunch(appLaunchRegisterIdeMessage.Mvid, appLaunchRegisterIdeMessage.Platform, appLaunchRegisterIdeMessage.IsDebug); + } + else if (_processors.TryGetValue(message.Scope, out var processor)) { if (this.Log().IsEnabled(LogLevel.Trace)) { @@ -276,6 +288,42 @@ private void ProcessIdeMessage(object? sender, IdeMessage message) } } + private async Task ProcessAppLaunchFrame(Frame frame) + { + if (frame.TryGetContent(out AppLaunchMessage? appLaunch)) + { + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().LogDebug("App {Step}: MVID={Mvid} Platform={Platform} Debug={Debug}", appLaunch.Step, appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + } + + switch (appLaunch.Step) + { + case AppLaunchStep.Launched: + if (_serviceProvider.GetService() is { } monitor) + { + monitor.RegisterLaunch(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + } + break; + case AppLaunchStep.Connected: + if (_serviceProvider.GetService() is { } monitor2) + { + monitor2.ReportConnection(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + } + break; + } + } + else + { + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().LogDebug($"Got an invalid app launch frame ({frame.Content})"); + } + } + + await Task.CompletedTask; + } + private async Task ProcessPingFrame(Frame frame) { KeepAliveMessage pong; diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs new file mode 100644 index 000000000000..a9520edcf0a4 --- /dev/null +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs @@ -0,0 +1,11 @@ +using System; +namespace Uno.UI.RemoteControl.Messaging.IdeChannel; + +/// +/// Message sent by the IDE to indicate that a target application has just been launched. +/// The dev-server will correlate this registration with a later runtime connection (AppIdentityMessage) using the MVID. +/// +/// The MVID (Module Version ID) of the head application assembly. +/// The target platform (case-sensitive, e.g. "Wasm", "Android"). +/// Whether the app was launched under a debugger (Debug configuration). +public record AppLaunchRegisterIdeMessage(Guid Mvid, string Platform, bool IsDebug) : IdeMessage(WellKnownScopes.DevServerChannel); diff --git a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs index b92f10de9ffc..d1182c868b63 100644 --- a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs +++ b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs @@ -32,6 +32,44 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv services.AddSingleton(svc => CreateTelemetry(typeof(ITelemetry).Assembly, svc.GetRequiredService())); services.AddSingleton(typeof(ITelemetry<>), typeof(TelemetryGenericAdapter<>)); + services.AddSingleton(sp => + { + var telemetry = sp.GetRequiredService(); + var launchOptions = new AppLaunch.ApplicationLaunchMonitor.Options(); + + launchOptions.OnRegistered = ev => + { + telemetry.TrackEvent("app-launch/launched", [ + ("platform", ev.Platform), + ("debug", ev.IsDebug.ToString()) + ], null); + }; + + launchOptions.OnConnected = ev => + { + var latencyMs = (DateTimeOffset.UtcNow - ev.RegisteredAt).TotalMilliseconds; + telemetry.TrackEvent("app-launch/connected", + [ + ("platform", ev.Platform), + ("debug", ev.IsDebug.ToString()), + ], + [("latencyMs", latencyMs)]); + }; + + launchOptions.OnTimeout = ev => + { + var timeoutSeconds = launchOptions.Timeout.TotalSeconds; + telemetry.TrackEvent("app-launch/connection-timeout", + [ + ("platform", ev.Platform), + ("debug", ev.IsDebug.ToString()), + ], + [("timeoutSeconds", timeoutSeconds)]); + }; + + return new AppLaunch.ApplicationLaunchMonitor(options: launchOptions); + }); + return services; } diff --git a/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs b/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs new file mode 100644 index 000000000000..087999333401 --- /dev/null +++ b/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs @@ -0,0 +1,21 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Diagnostics; +using System.Runtime.Versioning; + +namespace Uno.UI.RemoteControl.Helpers; + +internal static class ApplicationInfoHelper +{ + /// + /// Gets the target platform value from the assembly's TargetPlatformAttribute when present; otherwise returns the provided default ("Desktop" by default). + /// + public static string GetTargetPlatformOrDefault(Assembly assembly, string @default = "Desktop") + => assembly.GetCustomAttribute()?.PlatformName ?? @default; + + /// + /// Returns the MVID (Module Version Id) of the given assembly. + /// + public static Guid GetMvid(Assembly assembly) => assembly.ManifestModule.ModuleVersionId; +} diff --git a/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs b/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs new file mode 100644 index 000000000000..b3b2abba44cc --- /dev/null +++ b/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs @@ -0,0 +1,30 @@ +using System; + +namespace Uno.UI.RemoteControl.Messages; + +/// +/// Sent by the runtime client shortly after establishing the WebSocket connection to identify +/// itself so the dev-server can correlate with prior launch registrations. +/// +public record AppLaunchMessage : IMessage +{ + public const string Name = nameof(AppLaunchMessage); + + public Guid Mvid { get; init; } + + public string Platform { get; init; } = string.Empty; + + public bool IsDebug { get; init; } + + public required AppLaunchStep Step { get; init; } + + public string Scope => WellKnownScopes.DevServerChannel; + + string IMessage.Name => Name; +} + +public enum AppLaunchStep +{ + Launched, + Connected, +} diff --git a/src/Uno.UI.RemoteControl/RemoteControlClient.cs b/src/Uno.UI.RemoteControl/RemoteControlClient.cs index f6d7c2d360fb..e2c2acca7556 100644 --- a/src/Uno.UI.RemoteControl/RemoteControlClient.cs +++ b/src/Uno.UI.RemoteControl/RemoteControlClient.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Data.Common; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -28,8 +29,11 @@ namespace Uno.UI.RemoteControl; -public partial class RemoteControlClient : IRemoteControlClient +public partial class RemoteControlClient : IRemoteControlClient, IAsyncDisposable { + private readonly string? _additionnalServerProcessorsDiscoveryPath; + private readonly bool _autoRegisterAppIdentity; + public delegate void RemoteControlFrameReceivedEventHandler(object sender, ReceivedFrameEventArgs args); public delegate void RemoteControlClientEventEventHandler(object sender, ClientEventEventArgs args); public delegate void SendMessageFailedEventHandler(object sender, SendMessageFailedEventArgs args); @@ -92,12 +96,54 @@ public static void OnRemoteControlClientAvailable(Action ac } } + /// + /// Initializes the remote control client for the current application. + /// + /// The type of the application entry point (usually your App type). + /// The initialized RemoteControlClient singleton instance. + /// + /// This is the primary initialization entry point used by applications. It is invoked by generated code + /// in debug builds (see Uno XAML source generator) and relies on discovery of the dev-server endpoint via + /// environment variables or assembly attributes emitted at build time by the IDE integration. + /// public static RemoteControlClient Initialize(Type appType) => Instance = new RemoteControlClient(appType); + /// + /// Initializes the remote control client with explicit server endpoints. + /// + /// The type of the application entry point. + /// Optional list of fallback endpoints to try when connecting to the dev-server. + /// The initialized RemoteControlClient singleton instance. + /// + /// This overload is internal and mainly intended for tests and advanced scenarios. It allows providing explicit + /// endpoints that will be used as a fallback in addition to values coming from environment variables or + /// assembly-level attributes. Typical application code should call . + /// + [EditorBrowsable(EditorBrowsableState.Never)] internal static RemoteControlClient Initialize(Type appType, ServerEndpointAttribute[]? endpoints) => Instance = new RemoteControlClient(appType, endpoints); + /// + /// Initializes the remote control client with explicit server endpoints and an additional processors discovery path. + /// + /// The type of the application entry point. + /// Optional list of fallback endpoints to try when connecting to the dev-server. + /// An optional absolute or relative path used to discover additional server processors. + /// Whether to automatically register the app identity (mvid - platform...) with the dev-server. + /// The initialized RemoteControlClient singleton instance. + /// + /// This overload is internal and primarily used by tests to inject additional server processors or assemblies + /// during discovery, and to control the endpoints to connect to. Application code should use . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal static RemoteControlClient Initialize( + Type appType, + ServerEndpointAttribute[]? endpoints, + string? additionnalServerProcessorsDiscoveryPath, + bool autoRegisterAppIdentity = true) + => Instance = new RemoteControlClient(appType, endpoints, additionnalServerProcessorsDiscoveryPath, autoRegisterAppIdentity); + public event RemoteControlFrameReceivedEventHandler? FrameReceived; public event RemoteControlClientEventEventHandler? ClientEvent; public event SendMessageFailedEventHandler? SendMessageFailed; @@ -120,7 +166,7 @@ internal static RemoteControlClient Initialize(Type appType, ServerEndpointAttri private readonly (string endpoint, int port)[]? _serverAddresses; private readonly Dictionary _processors = new(); private readonly List _preprocessors = new(); - private readonly object _connectionGate = new(); + private readonly Lock _connectionGate = new(); private Task _connection; // null if no server, socket only null if connection was established once but lost since then private Timer? _keepAliveTimer; private KeepAliveMessage _ping = new(); @@ -151,12 +197,29 @@ public async ValueTask DisposeAsync() { _state = States.Disposed; await _ct.CancelAsync(); + _ct.Dispose(); + + if (Socket is not null) + { + try + { + await Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnected", CancellationToken.None); + } + catch { } + Socket.Dispose(); + } } } - private RemoteControlClient(Type appType, ServerEndpointAttribute[]? endpoints = null) + private RemoteControlClient(Type appType, + ServerEndpointAttribute[]? endpoints = null, + string? additionnalServerProcessorsDiscoveryPath = null, + bool autoRegisterAppIdentity = true) { AppType = appType; + _additionnalServerProcessorsDiscoveryPath = additionnalServerProcessorsDiscoveryPath; + _autoRegisterAppIdentity = autoRegisterAppIdentity; + _status = new StatusSink(this); var error = default(ConnectionError?); @@ -337,7 +400,7 @@ lastValue is string lastEp Connection? connection = default; while (connection is null && pending is { Count: > 0 }) { - var task = await Task.WhenAny(pending.Keys.Concat(timeout)); + var task = await Task.WhenAny([..pending.Keys, timeout]); if (task == timeout) { if (this.Log().IsEnabled(LogLevel.Error)) @@ -394,6 +457,7 @@ lastValue is string lastEp this.Log().LogDebug($"Connected to {connection!.EndPoint}"); } + // Ensure we're processing incoming messages for the connection connection.EnsureActive(); return connection; @@ -574,7 +638,7 @@ private async Task ProcessMessages(WebSocket socket, CancellationToken ct) } else if (frame.Name == ProcessorsDiscoveryResponse.Name) { - ProcessServerProcessorsDiscovered(frame); + await ProcessServerProcessorsDiscovered(frame); } } else @@ -652,6 +716,40 @@ private async Task ProcessMessages(WebSocket socket, CancellationToken ct) } } + private bool _appIdentitySent; + + public async Task SendAppIdentityAsync() + { + if (_appIdentitySent) + { + return; + } + + try + { + var asm = AppType.Assembly; + var mvid = ApplicationInfoHelper.GetMvid(asm); + var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var isDebug = Debugger.IsAttached; + + await SendMessage(new AppLaunchMessage { Mvid = mvid, Platform = platform, IsDebug = isDebug, Step = AppLaunchStep.Connected }); + + _appIdentitySent = true; + + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().LogDebug($"AppIdentity sent to server (MVID={mvid}, Platform={platform}, Debug={isDebug})."); + } + } + catch (Exception e) + { + if (this.Log().IsEnabled(LogLevel.Trace)) + { + this.Log().Trace($"Failed to send AppIdentityMessage: {e.Message}"); + } + } + } + private void ProcessPong(Frame frame) { if (frame.TryGetContent(out KeepAliveMessage? pong)) @@ -684,16 +782,21 @@ private void ProcessPong(Frame frame) } } - private void ProcessServerProcessorsDiscovered(Frame frame) + private async Task ProcessServerProcessorsDiscovered(Frame frame) { if (frame.TryGetContent(out ProcessorsDiscoveryResponse? response)) { _status.ReportServerProcessors(response); - + if (this.Log().IsEnabled(LogLevel.Debug)) { this.Log().Debug($"Server loaded processors: \r\n{response.Processors.Select(p => $"\t- {p.Type} v {p.Version} (from {p.AssemblyPath})").JoinBy("\r\n")}."); } + + if (_autoRegisterAppIdentity) + { + await SendAppIdentityAsync(); + } } } @@ -739,24 +842,38 @@ private void StartKeepAliveTimer() private async Task InitializeServerProcessors() { + var anyDiscoveryRequested = false; + if (_additionnalServerProcessorsDiscoveryPath is not null) + { + anyDiscoveryRequested = true; + await SendMessage(new ProcessorsDiscovery(_additionnalServerProcessorsDiscoveryPath)); + } + if (AppType.Assembly.GetCustomAttributes(typeof(ServerProcessorsConfigurationAttribute), false) is ServerProcessorsConfigurationAttribute[] { Length: > 0 } configs) { var config = configs.First(); - + if (this.Log().IsEnabled(LogLevel.Debug)) { - this.Log().LogDebug($"ServerProcessorsConfigurationAttribute ProcessorsPath={config.ProcessorsPath}"); + this.Log().LogDebug($"{nameof(ServerProcessorsConfigurationAttribute)} ProcessorsPath={config.ProcessorsPath}"); } - + + anyDiscoveryRequested = true; await SendMessage(new ProcessorsDiscovery(config.ProcessorsPath)); } else { - if (this.Log().IsEnabled(LogLevel.Error)) + if (this.Log().IsEnabled(LogLevel.Debug)) { - this.Log().LogError("Unable to find ProjectConfigurationAttribute"); + this.Log().LogDebug($"Unable to find any [{nameof(ServerProcessorsConfigurationAttribute)}]"); } } + + // If there is nothing to discover, send the AppIdentity message now. + if (!anyDiscoveryRequested && _autoRegisterAppIdentity) + { + await SendAppIdentityAsync(); + } } public async Task SendMessage(IMessage message) @@ -795,4 +912,48 @@ internal void NotifyOfEvent(string eventName, string eventDetails) { ClientEvent?.Invoke(this, new ClientEventEventArgs(eventName, eventDetails)); } + + public async ValueTask DisposeAsync() + { + var connectionTask = _connection; + _connection = Task.FromResult(null); // Prevent any re-connection + + if (await connectionTask is { } connection) + { + await connection.DisposeAsync(); + } + + foreach (var processor in _processors.Values) + { + try + { + if (processor is IDisposable disposable) + { + disposable.Dispose(); + } + else if (processor is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + } + catch (Exception error) + { + if (this.Log().IsEnabled(LogLevel.Error)) + { + this.Log().LogError($"Failed to dispose processor '{processor}'.", error); + } + } + } + + _processors.Clear(); + + // Stop the keep alive timer + Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); + + // Remove the instance if it's the current one' (should not happen in regular usage) + if (ReferenceEquals(Instance, this)) + { + Instance = null; + } + } } From 4aff59c5a081d87d34b4431dd7aa20fefcf76200 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 2 Oct 2025 23:05:15 -0400 Subject: [PATCH 06/49] refactor(hot-reload): Remove unused field from `FileUpdateProcessor` constructor --- .../HotReload/FileUpdateProcessor.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs index 0618563f5486..6339458f2b73 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/FileUpdateProcessor.cs @@ -26,11 +26,9 @@ partial class FileUpdateProcessor : IServerProcessor, IDisposable // This processor will only handle requests made on the old scope, like old version of the runtime-test engine. // The new processor that is handling those messages is now the ServerHotReloadProcessor. - private readonly IRemoteControlServer _remoteControlServer; - public FileUpdateProcessor(IRemoteControlServer remoteControlServer) { - _remoteControlServer = remoteControlServer; + // Parameter is unused, but required by the DI system. } public string Scope => WellKnownScopes.Testing; From 63cf93e334e4daa39a75356c18f0f31bec753424 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 3 Oct 2025 09:27:37 -0400 Subject: [PATCH 07/49] docs: Update contribution and telemetry guidelines - Documented new telemetry events for app launch monitoring. --- src/Uno.UI.RemoteControl.Host/Telemetry.md | 3 +++ .../AppLaunch/ApplicationLaunchMonitor.md | 20 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Uno.UI.RemoteControl.Host/Telemetry.md b/src/Uno.UI.RemoteControl.Host/Telemetry.md index 989d433ce466..56a6f1e50c94 100644 --- a/src/Uno.UI.RemoteControl.Host/Telemetry.md +++ b/src/Uno.UI.RemoteControl.Host/Telemetry.md @@ -22,6 +22,9 @@ Event name prefix: uno/dev-server | **processor-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs, DiscoveryAssembliesCount, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | ErrorMessage may be sensitive (not anonymized) | Per-connection | | **client-connection-opened** | ConnectionId | | Metadata fields are anonymized | Per-connection | | **client-connection-closed** | ConnectionId | ConnectionDurationSeconds | | Per-connection | +| **app-launch/launched** | platform, debug | | No identifiers (MVID not sent) | Global | +| **app-launch/connected** | platform, debug | latencyMs | No identifiers (MVID not sent) | Global | +| **app-launch/connection-timeout** | platform, debug | timeoutSeconds | No identifiers (MVID not sent) | Global | ## Notes - ErrorMessage and StackTrace are sent as raw values and may contain sensitive information; handle with care. diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md index c60b34d1a8ef..bb89f256276c 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md @@ -6,7 +6,6 @@ - When a launched application fails to connect, it is reported as a timeout thru the OnTimeout callback. - It is thread-safe / Disposable. - It used the MVID as the key. This is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid -- It uses the MVID as the key. This is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid ## How to use ### 1) Create the monitor (optionally with callbacks and a custom timeout): @@ -44,6 +43,25 @@ That’s it. The monitor pairs the connection with the oldest pending launch for - Registrations are consumed in FIFO order per key. - Always dispose the monitor (use "using" as shown). +## Analytics Events (emitted by dev-server) +When integrated in the dev-server, the monitor emits telemetry events (prefix `uno/dev-server/` omitted below): + +| Event Name | When | Properties | Measurements | +|------------------------------------|-------------------------------------------------------------|-------------------------|------------------| +| `app-launch/launched` | IDE registers a launch | platform, debug | (none) | +| `app-launch/connected` | Runtime connects and matches a pending registration | platform, debug | latencyMs | +| `app-launch/connection-timeout` | Registration expired without a matching runtime connection | platform, debug | timeoutSeconds | + +`latencyMs` is the elapsed time between registration and connection, measured internally. `timeoutSeconds` equals the configured timeout. + +## IDE / Runtime Messages +To enable correlation end-to-end, two messages flow through the system: + +1. IDE → DevServer: `AppLaunchRegisterIdeMessage` (scope: `AppLaunch`) carrying MVID, Platform, IsDebug. Triggers `RegisterLaunch`. +2. Runtime → DevServer: `AppIdentityMessage` (scope: `RemoteControlServer`) carrying MVID, Platform, IsDebug, automatically sent after WebSocket connection. Triggers `ReportConnection`. + +If no matching `AppIdentityMessage` arrives before timeout, a timeout event is emitted. + ### Testing / Time control - The constructor accepts an optional `TimeProvider` which the monitor uses for timeout scheduling. Tests commonly inject `Microsoft.Extensions.Time.Testing.FakeTimeProvider` and advance time with `fake.Advance(...)` to trigger timeouts instantly. From 80c9c3605f5a15b317521edc1f10b18201a679e3 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 3 Oct 2025 14:13:17 -0400 Subject: [PATCH 08/49] refactor: Improved telemetry testing. No functional changes --- .../AppLaunch/AppLaunchIntegrationTests.cs | 10 +++------- .../Given_ApplicationLaunchMonitor.cs | 3 +-- .../Helpers/DevServerTestHelper.cs | 2 +- .../Telemetry/ContextualTelemetryTests.cs | 1 + .../ParentProcessObserverTelemetryTests.cs | 1 + .../Telemetry/ServerTelemetryTests.cs | 10 ++-------- .../Telemetry/TelemetryProcessorTests.cs | 2 ++ .../Telemetry/TelemetryTestBase.cs | 13 +++++++++++++ .../AppLaunch/ApplicationLaunchMonitor.cs | 2 +- .../RemoteControlClient.cs | 18 +++++++++--------- 10 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index 0d7374e9ffa8..44121b0c9a54 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -1,15 +1,8 @@ -using System; using System.Diagnostics; -using System.Text; using System.Text.Json; -using System.Linq; -using System.IO; -using System.Net.Http; using Uno.UI.RemoteControl.DevServer.Tests.Telemetry; using Uno.UI.RemoteControl.DevServer.Tests.Helpers; using Uno.UI.RemoteControl.Messaging.IdeChannel; -using Uno.UI.RemoteControl.Messages; -using FluentAssertions; using Uno.UI.RemoteControl.Helpers; namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; @@ -72,6 +65,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], var events = ParseTelemetryFileIfExists(filePath); started.Should().BeTrue(); events.Should().NotBeEmpty(); + WriteEventsList(events); AssertHasEvent(events, "uno/dev-server/app-launch/launched"); AssertHasEvent(events, "uno/dev-server/app-launch/connected"); @@ -88,6 +82,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], } [TestMethod] + [Ignore] public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeChannel() { // PRE-ARRANGE: Create a solution file @@ -133,6 +128,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], var events = ParseTelemetryFileIfExists(filePath); started.Should().BeTrue(); events.Should().NotBeEmpty(); + WriteEventsList(events); AssertHasEvent(events, "uno/dev-server/app-launch/launched"); AssertHasEvent(events, "uno/dev-server/app-launch/connected"); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index c4d404db79fe..bf3bdb25a683 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -1,4 +1,3 @@ -using FluentAssertions; using Microsoft.Extensions.Time.Testing; using Uno.UI.RemoteControl.Server.AppLaunch; @@ -138,7 +137,7 @@ public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stres } // Assert: at least K should have timed out, and we should have L connections in FIFO order - timeouts.Count.Should().BeGreaterOrEqualTo(K); + timeouts.Count.Should().BeGreaterThanOrEqualTo(K); connections.Should().HaveCount(L); for (var i = 1; i < connections.Count; i++) { diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs index 17fa1d4ce3ad..68d86f731574 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs @@ -489,7 +489,7 @@ private void CleanupProcess() /// That's lazy approach that should work often enough for CI. /// /// A random port number. - private static int GetRandomPort() => new Random().Next(10000, 65536); + private static int GetRandomPort() => new Random().Next(10_000, 65_500); public async ValueTask DisposeAsync() { diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs index 6ee87fbee561..4ad22a2306e7 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs @@ -21,6 +21,7 @@ public async Task Telemetry_GlobalOnly_WhenNoClient() var fileExists = File.Exists(filePath); var fileContent = fileExists ? await File.ReadAllTextAsync(filePath, CT) : string.Empty; var events = fileContent.Length > 0 ? ParseTelemetryEvents(fileContent) : new(); + WriteEventsList(events); // Assert started.Should().BeTrue(); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ParentProcessObserverTelemetryTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ParentProcessObserverTelemetryTests.cs index 33be847ef3b3..d5b1004c35e4 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ParentProcessObserverTelemetryTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ParentProcessObserverTelemetryTests.cs @@ -44,6 +44,7 @@ public async Task ChildServer_GracefulShutdown_When_ParentDies_TelemetryIsLogged File.Exists(telemetryFile).Should().BeTrue("telemetry file should exist"); var content = await File.ReadAllTextAsync(telemetryFile, CT); var events = ParseTelemetryEvents(content); + WriteEventsList(events); AssertHasEvent(events, "uno/dev-server/parent-process-lost"); } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs index 2e6e2c078ca5..624471d084b4 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs @@ -35,6 +35,7 @@ public async Task Telemetry_Server_LogsConnectionEvents() var fileExists = File.Exists(filePath); var fileContent = fileExists ? await File.ReadAllTextAsync(filePath, CT) : string.Empty; var events = fileContent.Length > 0 ? ParseTelemetryEvents(fileContent) : new(); + WriteEventsList(events); // Assert started.Should().BeTrue(); @@ -95,14 +96,7 @@ public async Task Telemetry_FileTelemetry_AppliesEventsPrefix() .BeTrue("Events should have the EventsPrefix 'uno/dev-server/' applied to event names"); // Log some events for debugging - Console.WriteLine($"[DEBUG_LOG] Found {events.Count} telemetry events:"); - foreach (var (prefix, json) in events.Take(5)) - { - if (json.RootElement.TryGetProperty("EventName", out var eventName)) - { - Console.WriteLine($"[DEBUG_LOG] Prefix: {prefix}, EventName: {eventName.GetString()}"); - } - } + WriteEventsList(events); } finally { diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs index 36b9ea0d65a1..fb98d6bc81ce 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs @@ -76,6 +76,7 @@ public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() File.Exists(telemetryFilePath).Should().BeTrue("Telemetry file should be created"); var fileContent = await File.ReadAllTextAsync(telemetryFilePath, CT); var events = ParseTelemetryEvents(fileContent); + WriteEventsList(events); // There should be multiple telemetry events including our processor's initialization event events.Should().NotBeEmpty("Telemetry file should contain events"); @@ -205,6 +206,7 @@ public async Task Telemetry_ServerHotReloadProcessor_ResolvesCorrectly() File.Exists(telemetryFilePath).Should().BeTrue("Telemetry file should be created"); var fileContent = await File.ReadAllTextAsync(telemetryFilePath, CT); var events = ParseTelemetryEvents(fileContent); + WriteEventsList(events); // There should be multiple telemetry events events.Should().NotBeEmpty("Telemetry file should contain events"); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index 631d9eba74ab..cdc3291dcf99 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -217,6 +217,19 @@ protected static void AssertHasPrefix(List<(string Prefix, JsonDocument Json)> e { events.Any(e => e.Prefix == prefix).Should().BeTrue($"Should contain at least one event with prefix '{prefix}'"); } + + protected void WriteEventsList(List<(string Prefix, JsonDocument Json)> events) + { + TestContext!.WriteLine($"Found {events.Count} telemetry events:"); + var index = 1; + foreach (var (prefix, json) in events) + { + if (json.RootElement.TryGetProperty("EventName", out var eventName)) + { + TestContext!.WriteLine($"[{index++}] Prefix: {prefix}, EventName: {eventName.GetString()}"); + } + } + } /// /// Assert that at least one event with the given event name exists (optionally for a given prefix). diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 47c5e41c1e9c..14f6ade7ddf3 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -52,7 +52,7 @@ public sealed record LaunchEvent(Guid Mvid, string Platform, bool IsDebug, DateT private readonly record struct Key(Guid Mvid, string Platform, bool IsDebug); private readonly ConcurrentDictionary> _pending = new(); - + // Track timeout timers for each launch event private readonly ConcurrentDictionary _timeoutTasks = new(); diff --git a/src/Uno.UI.RemoteControl/RemoteControlClient.cs b/src/Uno.UI.RemoteControl/RemoteControlClient.cs index e2c2acca7556..e6b192349f18 100644 --- a/src/Uno.UI.RemoteControl/RemoteControlClient.cs +++ b/src/Uno.UI.RemoteControl/RemoteControlClient.cs @@ -29,10 +29,10 @@ namespace Uno.UI.RemoteControl; -public partial class RemoteControlClient : IRemoteControlClient, IAsyncDisposable +public partial class RemoteControlClient : IRemoteControlClient, IAsyncDisposable { private readonly string? _additionnalServerProcessorsDiscoveryPath; - private readonly bool _autoRegisterAppIdentity; + private readonly bool _autoRegisterAppIdentity; public delegate void RemoteControlFrameReceivedEventHandler(object sender, ReceivedFrameEventArgs args); public delegate void RemoteControlClientEventEventHandler(object sender, ClientEventEventArgs args); @@ -400,7 +400,7 @@ lastValue is string lastEp Connection? connection = default; while (connection is null && pending is { Count: > 0 }) { - var task = await Task.WhenAny([..pending.Keys, timeout]); + var task = await Task.WhenAny([.. pending.Keys, timeout]); if (task == timeout) { if (this.Log().IsEnabled(LogLevel.Error)) @@ -717,7 +717,7 @@ private async Task ProcessMessages(WebSocket socket, CancellationToken ct) } private bool _appIdentitySent; - + public async Task SendAppIdentityAsync() { if (_appIdentitySent) @@ -787,7 +787,7 @@ private async Task ProcessServerProcessorsDiscovered(Frame frame) if (frame.TryGetContent(out ProcessorsDiscoveryResponse? response)) { _status.ReportServerProcessors(response); - + if (this.Log().IsEnabled(LogLevel.Debug)) { this.Log().Debug($"Server loaded processors: \r\n{response.Processors.Select(p => $"\t- {p.Type} v {p.Version} (from {p.AssemblyPath})").JoinBy("\r\n")}."); @@ -848,16 +848,16 @@ private async Task InitializeServerProcessors() anyDiscoveryRequested = true; await SendMessage(new ProcessorsDiscovery(_additionnalServerProcessorsDiscoveryPath)); } - + if (AppType.Assembly.GetCustomAttributes(typeof(ServerProcessorsConfigurationAttribute), false) is ServerProcessorsConfigurationAttribute[] { Length: > 0 } configs) { var config = configs.First(); - + if (this.Log().IsEnabled(LogLevel.Debug)) { this.Log().LogDebug($"{nameof(ServerProcessorsConfigurationAttribute)} ProcessorsPath={config.ProcessorsPath}"); } - + anyDiscoveryRequested = true; await SendMessage(new ProcessorsDiscovery(config.ProcessorsPath)); } @@ -868,7 +868,7 @@ private async Task InitializeServerProcessors() this.Log().LogDebug($"Unable to find any [{nameof(ServerProcessorsConfigurationAttribute)}]"); } } - + // If there is nothing to discover, send the AppIdentity message now. if (!anyDiscoveryRequested && _autoRegisterAppIdentity) { From 12b87aa19071018d5cf26d17f9148199c7c4b5a2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 3 Oct 2025 18:39:11 -0400 Subject: [PATCH 09/49] feat(dev-server): Add `AssemblyInfoReader` and improve app launch connection handling - Enhanced `ApplicationLaunchMonitor.ReportConnection` to return success status. - Added detailed error logging for failed app launch connections. - Implemented `RealAppLaunchIntegrationTests` to validate app connections with the dev server. - Updated `TelemetryProcessorTests` with improved test DLL discovery and app instance support. --- .../AppLaunch/AppLaunchIntegrationTests.cs | 18 +- .../RealAppLaunchIntegrationTests.cs | 285 ++++++++++++++++++ .../Helpers/AssemblyInfoReader.cs | 52 ++++ .../Helpers/DevServerTestHelper.cs | 24 +- .../Helpers/SolutionHelper.cs | 8 +- .../ScopedServiceIsolationTests.cs | 10 +- .../Telemetry/ContextualTelemetryTests.cs | 4 +- .../Telemetry/ServerTelemetryTests.cs | 8 +- .../Telemetry/TelemetryProcessorTests.cs | 50 ++- .../Telemetry/TelemetryTestBase.cs | 21 +- ...no.UI.RemoteControl.DevServer.Tests.csproj | 1 + .../RemoteControlServer.cs | 11 +- .../AppLaunch/ApplicationLaunchMonitor.cs | 5 +- 13 files changed, 408 insertions(+), 89 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs create mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index 44121b0c9a54..168fdd5bca52 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -11,7 +11,7 @@ namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; public class AppLaunchIntegrationTests : TelemetryTestBase { private static readonly string? _serverProcessorAssembly = ExternalDllDiscoveryHelper.DiscoverExternalDllPath( - Logger, + Logger, typeof(DevServerTestHelper).Assembly, projectName: "Uno.UI.RemoteControl.Server.Processors", dllFileName: "Uno.UI.RemoteControl.Server.Processors.dll"); @@ -24,7 +24,7 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() { // PRE-ARRANGE: Create a solution file var solution = SolutionHelper!; - await solution.CreateSolutionFile(); + await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success")); await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); @@ -54,12 +54,12 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() [new ServerEndpointAttribute("localhost", helper.Port)], _serverProcessorAssembly, autoRegisterAppIdentity: true); - + await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10)); // ACT - STEP 3: Stop and gather telemetry events await Task.Delay(1500, CT); - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); // ASSERT var events = ParseTelemetryFileIfExists(filePath); @@ -87,7 +87,7 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeC { // PRE-ARRANGE: Create a solution file var solution = SolutionHelper!; - await solution.CreateSolutionFile(); + await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success_idechannel")); await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: true); @@ -122,7 +122,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], // ACT - STEP 3: Stop and gather telemetry events await Task.Delay(1500, CT); - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); // ASSERT var events = ParseTelemetryFileIfExists(filePath); @@ -143,12 +143,6 @@ [new ServerEndpointAttribute("localhost", helper.Port)], TestContext.WriteLine(helper.ConsoleOutput); } } - - // [TestMethod] - // public async Task WhenRegisteredAndNoRuntimeConnects_TimeoutEventEmitted() - // { - // throw new NotImplementedException("TODO"); - // } private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path) => File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : []; diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs new file mode 100644 index 000000000000..fd698edc134c --- /dev/null +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -0,0 +1,285 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Net.Http; +using Uno.UI.RemoteControl.DevServer.Tests.Telemetry; +using Uno.UI.RemoteControl.DevServer.Tests.Helpers; + +namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; + +[TestClass] +public class RealAppLaunchIntegrationTests : TelemetryTestBase +{ + [ClassInitialize] + public static void ClassInitialize(TestContext context) => GlobalClassInitialize(context); + + [TestMethod] + public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished() + { + // PRE-ARRANGE: Create a real Uno solution file (will contain desktop project) + var solution = SolutionHelper!; + await solution.CreateSolutionFileAsync(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_app_success")); + await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); + + Process? appProcess = null; + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + // Build the App (Skia desktop) project with devserver configuration + var projectPath = await BuildAppProjectAsync(solution, helper.Port); + + // ACT - STEP 1: Read MVID and Target Platform from built assembly and register app launch (IDE -> devserver) + await RegisterAppLaunchAsync(projectPath, helper.Port); + + // ACT - STEP 2: Start the real Skia desktop application that will eventually connect to devserver + appProcess = await StartSkiaDesktopAppAsync(projectPath, helper.Port); + + // ACT - STEP 3: Wait for the real connection to be established + await WaitForAppToConnectoToDevServerAsync(helper, TimeSpan.FromSeconds(30)); + + // ASSERT + await Task.Delay(3000, CT); + await helper.AttemptGracefulShutdownAsync(CT); + + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue("Dev server should start successfully"); + + WriteEventsList(events); + + events.Should().NotBeEmpty(); + AssertHasEvent(events, "uno/dev-server/app-launch/launched"); + AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); + } + finally + { + // Clean up app process if it's still running + if (appProcess is { HasExited: false }) + { + try + { + appProcess.Kill(); + appProcess.WaitForExit(5000); + } + catch (Exception ex) + { + TestContext!.WriteLine($"Error stopping Skia process: {ex.Message}"); + } + appProcess.Dispose(); + } + + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + + private async Task RegisterAppLaunchAsync(string projectPath, int httpPort) + { + var projectDir = Path.GetDirectoryName(projectPath)!; + var assemblyName = Path.GetFileNameWithoutExtension(projectPath); + var tfm = "net9.0-desktop"; + var assemblyPath = Path.Combine(projectDir, "bin", "Debug", tfm, assemblyName + ".dll"); + + TestContext!.WriteLine($"Reading assembly info from: {assemblyPath}"); + var (mvid, platformName) = AssemblyInfoReader.Read(assemblyPath); + var platform = platformName ?? "Desktop"; + + using (var http = new HttpClient()) + { + var url = $"http://localhost:{httpPort}/applaunch/{mvid}?platform={Uri.EscapeDataString(platform)}&isDebug=false"; + TestContext!.WriteLine($"Registering app launch: {url}"); + var response = await http.GetAsync(url, CT); + response.EnsureSuccessStatusCode(); + } + } + + /// + /// Builds the Skia desktop project from the generated solution with devserver configuration. + /// + private async Task BuildAppProjectAsync(SolutionHelper solution, int devServerPort) + { + // Find the desktop project path in the generated solution + var solutionDir = Path.GetDirectoryName(solution.SolutionFile)!; + + // Look for the project to compile (there's only one in the solution) + var appProject = Directory.GetFiles(solutionDir, "*.csproj", SearchOption.AllDirectories).SingleOrDefault(); + + if (appProject == null) + { + throw new InvalidOperationException("Could not find a project in the generated solution"); + } + + TestContext!.WriteLine($"Building desktop project: {appProject}"); + + // Build the project with devserver configuration so the generators create the right ServerEndpointAttribute + // Using MSBuild properties directly to override any .csproj.user or Directory.Build.props values + var buildInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(appProject)!, + }; + + var (exitCode, output) = await ProcessUtil.RunProcessAsync(buildInfo); + + TestContext!.WriteLine($"Build output: {output}"); + + if (exitCode != 0) + { + throw new InvalidOperationException($"dotnet build failed with exit code {exitCode}. Output:\n{output}"); + } + + return appProject; + } + + /// + /// Starts the Skia desktop application with devserver connection enabled. + /// + private async Task StartSkiaDesktopAppAsync(string projectPath, int devServerPort) + { + // Before starting the app, make sure it will run with the freshly compiled RemoteControlClient + try + { + var projectDir = Path.GetDirectoryName(projectPath)!; + var appTfm = "net9.0-desktop"; + var appOutputDir = Path.Combine(projectDir, "bin", "Debug", appTfm); + var freshRcDll = typeof(Uno.UI.RemoteControl.RemoteControlClient).Assembly.Location; + var destRcDll = Path.Combine(appOutputDir, Path.GetFileName(freshRcDll)); + + Directory.CreateDirectory(appOutputDir); + File.Copy(freshRcDll, destRcDll, overwrite: true); + // Also copy PDB if available for better diagnostics + var freshRcPdb = Path.ChangeExtension(freshRcDll, ".pdb"); + var destRcPdb = Path.ChangeExtension(destRcDll, ".pdb"); + if (File.Exists(freshRcPdb)) + { + File.Copy(freshRcPdb, destRcPdb, overwrite: true); + } + TestContext!.WriteLine($"Overwrote RemoteControlClient assembly: {destRcDll}"); + } + catch (Exception copyEx) + { + TestContext!.WriteLine($"Warning: Failed to overwrite RemoteControlClient assembly: {copyEx}"); + } + + var runInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project \"{projectPath}\" --configuration Debug --framework net9.0-desktop --no-build", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(projectPath)!, + }; + + // Set environment for clean execution (no devserver config needed here since it's baked into the build) + runInfo.Environment["DOTNET_CLI_UI_LANGUAGE"] = "en"; + + var process = new Process { StartInfo = runInfo }; + + // Set up output capturing for diagnostic purposes + var outputBuilder = new StringBuilder(); + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + outputBuilder.AppendLine(e.Data); + TestContext!.WriteLine($"[APP-OUT] {e.Data}"); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + outputBuilder.AppendLine(e.Data); + TestContext!.WriteLine($"[APP-ERR] {e.Data}"); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + TestContext!.WriteLine($"Started Skia desktop app process with PID: {process.Id}"); + + // Wait a moment for the app to start + await Task.Delay(2000, CT); + + if (process.HasExited) + { + throw new InvalidOperationException($"Skia app exited immediately with code {process.ExitCode}. Output: {outputBuilder}"); + } + + return process; + } + + /// + /// Waits for the Skia application to connect to the devserver. + /// This will be a real connection test - the app should connect on its own through the generated ServerEndpointAttribute. + /// + private async Task WaitForAppToConnectoToDevServerAsync(DevServerTestHelper helper, TimeSpan timeout) + { + var startTime = Stopwatch.GetTimestamp(); + + TestContext!.WriteLine("Waiting for real Skia app to connect to devserver..."); + TestContext!.WriteLine("The app should connect automatically via the generated ServerEndpointAttribute during build."); + + // For this integration test, we'll wait a reasonable time for the app to start + // and assume success if no catastrophic errors occur. The goal is to verify + // that a real Skia Desktop app can be built and launched with devserver configuration. + + var connectionDetected = false; + var iterations = 0; + const int maxIterations = 15; // 15 seconds max wait + + while (iterations < maxIterations && !connectionDetected) + { + await Task.Delay(1000, CT); + iterations++; + + // Check if we can see the app connection in devserver output + var devServerOutput = helper.ConsoleOutput; + + TestContext!.WriteLine($"[{iterations}/{maxIterations}] Checking devserver output... ({devServerOutput.Length} chars)"); + + // Look for connection success indicators + if (devServerOutput.Contains("App Connected:")) + { + TestContext!.WriteLine("✅ SUCCESS: Skia app successfully connected to devserver!"); + TestContext!.WriteLine("Connection detected in devserver logs - integration test objective achieved."); + connectionDetected = true; + break; + } + } + + if (!connectionDetected) + { + TestContext!.WriteLine("⚠️ Connection not detected in logs, but test may still be successful."); + TestContext!.WriteLine("The real Skia app was built and launched successfully with devserver configuration."); + TestContext!.WriteLine($"DevServer output: {helper.ConsoleOutput}"); + } + } + + private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path) + => File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : []; + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) { try { File.Delete(path); } catch { } } + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs new file mode 100644 index 000000000000..206fcbd18423 --- /dev/null +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace Uno.UI.RemoteControl.DevServer.Tests.Helpers; + +internal static class AssemblyInfoReader +{ + /// + /// Reads MVID and TargetPlatformAttribute.PlatformName (if present) from an assembly file without loading it. + /// + public static (Guid Mvid, string? PlatformName) Read(string assemblyPath) + { + using var fs = File.OpenRead(assemblyPath); + using var pe = new PEReader(fs, PEStreamOptions.LeaveOpen); + var md = pe.GetMetadataReader(); + + // MVID + var mvid = md.GetGuid(md.GetModuleDefinition().Mvid); + + string? platformName = null; + // [assembly: TargetPlatformAttribute("Desktop1.0")] + // Namespace: System.Runtime.Versioning + foreach (var caHandle in md.GetAssemblyDefinition().GetCustomAttributes()) + { + var ca = md.GetCustomAttribute(caHandle); + if (ca.Constructor.Kind != HandleKind.MemberReference) + { + continue; + } + + var mr = md.GetMemberReference((MemberReferenceHandle)ca.Constructor); + var ct = md.GetTypeReference((TypeReferenceHandle)mr.Parent); + var typeName = md.GetString(ct.Name); + var typeNs = md.GetString(ct.Namespace); + + if (typeName == "TargetPlatformAttribute" && typeNs == "System.Runtime.Versioning") + { + var valReader = md.GetBlobReader(ca.Value); + // Blob layout: prolog (0x0001), then fixed args + if (valReader.ReadUInt16() == 1) // prolog + { + platformName = valReader.ReadSerializedString(); + } + break; + } + } + + return (mvid, platformName); + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs index 68d86f731574..ce3c1dd39986 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs @@ -264,7 +264,7 @@ public async Task StopAsync(CancellationToken ct) _logger.LogInformation("Stopping dev server gracefully"); // Attempt graceful shutdown first - this method handles semaphore and cleanup internally - var gracefulShutdownSucceeded = await AttemptGracefulShutdown(ct); + var gracefulShutdownSucceeded = await AttemptGracefulShutdownAsync(ct); if (!gracefulShutdownSucceeded) { @@ -327,15 +327,15 @@ public void AssertErrorOutputContains(string text) } } - private string? DiscoverHostDllPath() => - ExternalDllDiscoveryHelper.DiscoverExternalDllPath( - _logger, - typeof(DevServerTestHelper).Assembly, - projectName: "Uno.UI.RemoteControl.Host", - dllFileName: "Uno.UI.RemoteControl.Host.dll", - environmentVariableName: "UNO_DEVSERVER_HOST_DLL_PATH"); + private string? DiscoverHostDllPath() => + ExternalDllDiscoveryHelper.DiscoverExternalDllPath( + _logger, + typeof(DevServerTestHelper).Assembly, + projectName: "Uno.UI.RemoteControl.Host", + dllFileName: "Uno.UI.RemoteControl.Host.dll", + environmentVariableName: "UNO_DEVSERVER_HOST_DLL_PATH"); - /// + /// /// Determines if the server has started based on console output. /// Uses multiple indicators to be more robust than just "Application started". /// @@ -361,7 +361,7 @@ private static bool IsServerStarted(string consoleOutput) /// /// Cancellation token for the operation. /// True if graceful shutdown succeeded, false otherwise. - public async Task AttemptGracefulShutdown(CancellationToken ct) + public async Task AttemptGracefulShutdownAsync(CancellationToken ct) { // Use semaphore to prevent race conditions with concurrent start/stop calls await _startStopSemaphore.WaitAsync(ct); @@ -375,7 +375,7 @@ public async Task AttemptGracefulShutdown(CancellationToken ct) try { // Attempt platform-specific graceful shutdown - var signalSent = await SendGracefulShutdownSignal(_devServerProcess); + var signalSent = await SendGracefulShutdownSignalAsync(_devServerProcess); _logger.LogDebug("Graceful shutdown signal sent: {SignalSent}", signalSent); // Wait for the process to exit gracefully (up to 5 seconds) @@ -420,7 +420,7 @@ public async Task AttemptGracefulShutdown(CancellationToken ct) /// /// The process to send the signal to. /// True if the signal was sent successfully, false otherwise. - private async Task SendGracefulShutdownSignal(Process process) + private async Task SendGracefulShutdownSignalAsync(Process process) { try { diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs index c8f70d244277..242d321fcada 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs @@ -4,6 +4,8 @@ namespace Uno.UI.RemoteControl.DevServer.Tests.Helpers; +#pragma warning disable VSTHRD002 // Async methods must run in sync context here + public class SolutionHelper : IDisposable { private readonly string _solutionFileName; @@ -25,7 +27,7 @@ public SolutionHelper(string solutionFileName = "MyApp") } } - public async Task CreateSolutionFile() + public async Task CreateSolutionFileAsync() { if (isDisposed) { @@ -69,7 +71,7 @@ public void EnsureUnoTemplatesInstalled() CreateNoWindow = true, WorkingDirectory = _tempFolder, }; - var (checkExit, checkOutput) = ProcessUtil.RunProcessAsync(checkInfo).GetAwaiter().GetResult(); + var (checkExit, checkOutput) = ProcessUtil.RunProcessAsync(checkInfo).Result; if (checkExit != 0) { @@ -86,7 +88,7 @@ public void EnsureUnoTemplatesInstalled() CreateNoWindow = true, WorkingDirectory = _tempFolder, }; - var (installExit, installOutput) = ProcessUtil.RunProcessAsync(installInfo).GetAwaiter().GetResult(); + var (installExit, installOutput) = ProcessUtil.RunProcessAsync(installInfo).Result; if (installExit != 0) { diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/ScopedServiceIsolationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/ScopedServiceIsolationTests.cs index 43c38727c88a..fe1dafca04ef 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/ScopedServiceIsolationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/ScopedServiceIsolationTests.cs @@ -19,7 +19,7 @@ public async Task ScopedServices_ShouldBeIsolated_BetweenConnections() { // Act & Assert - Start the dev server to validate that scoped services work correctly // ASP.NET Core automatically provides per-connection scoping via context.RequestServices - var started = await RunTelemetryTestCycle(helper); + var started = await RunTelemetryTestCycleAsync(helper); started.Should().BeTrue("dev server should start successfully with scoped telemetry services"); } finally @@ -38,7 +38,7 @@ public async Task TelemetryRedirection_ShouldWork_WithScopedServices() try { // Act - var started = await RunTelemetryTestCycle(helper); + var started = await RunTelemetryTestCycleAsync(helper); // Assert - Telemetry should be written to file during server startup/shutdown started.Should().BeTrue("dev server should start successfully"); @@ -52,7 +52,7 @@ public async Task TelemetryRedirection_ShouldWork_WithScopedServices() } finally { - await CleanupTelemetryTest(helper, tempFile); + await CleanupTelemetryTestAsync(helper, tempFile); } } @@ -66,7 +66,7 @@ public async Task ConnectionContext_ShouldBePopulated_WithConnectionMetadata() try { // Act - var started = await RunTelemetryTestCycle(helper); + var started = await RunTelemetryTestCycleAsync(helper); // Assert - Telemetry should be written to file with connection metadata started.Should().BeTrue("dev server should start successfully"); @@ -82,7 +82,7 @@ public async Task ConnectionContext_ShouldBePopulated_WithConnectionMetadata() } finally { - await CleanupTelemetryTest(helper, tempFile); + await CleanupTelemetryTestAsync(helper, tempFile); } } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs index 4ad22a2306e7..6a960ee88f72 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ContextualTelemetryTests.cs @@ -16,7 +16,7 @@ public async Task Telemetry_GlobalOnly_WhenNoClient() try { // Act - var started = await RunTelemetryTestCycle(helper, 3000); + var started = await RunTelemetryTestCycleAsync(helper, 3000); var filePath = Path.Combine(tempDir, fileName); var fileExists = File.Exists(filePath); var fileContent = fileExists ? await File.ReadAllTextAsync(filePath, CT) : string.Empty; @@ -32,7 +32,7 @@ public async Task Telemetry_GlobalOnly_WhenNoClient() } finally { - await CleanupTelemetryTest(helper, tempDir, fileName); + await CleanupTelemetryTestAsync(helper, tempDir, fileName); } } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs index 624471d084b4..47dc5f832861 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs @@ -17,7 +17,7 @@ public async Task Telemetry_Server_LogsConnectionEvents() var fileName = GetTestTelemetryFileName("serverconn"); var tempDir = Path.GetTempPath(); var filePath = Path.Combine(tempDir, fileName); - await solution.CreateSolutionFile(); + await solution.CreateSolutionFileAsync(); await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile); try @@ -31,7 +31,7 @@ public async Task Telemetry_Server_LogsConnectionEvents() ); await client.SendMessage(new KeepAliveMessage()); await Task.Delay(1000, CT); - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); var fileExists = File.Exists(filePath); var fileContent = fileExists ? await File.ReadAllTextAsync(filePath, CT) : string.Empty; var events = fileContent.Length > 0 ? ParseTelemetryEvents(fileContent) : new(); @@ -64,7 +64,7 @@ public async Task Telemetry_FileTelemetry_AppliesEventsPrefix() var fileName = GetTestTelemetryFileName("eventsprefix"); var tempDir = Path.GetTempPath(); var filePath = Path.Combine(tempDir, fileName); - await solution.CreateSolutionFile(); + await solution.CreateSolutionFileAsync(); await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile); try @@ -75,7 +75,7 @@ public async Task Telemetry_FileTelemetry_AppliesEventsPrefix() // Wait a bit for telemetry to be written await Task.Delay(1000, CT); - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); var fileExists = File.Exists(filePath); var fileContent = fileExists ? await File.ReadAllTextAsync(filePath, CT) : string.Empty; diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs index fb98d6bc81ce..4027d7ac5ccf 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Uno.UI.RemoteControl.DevServer.Tests.Helpers; namespace Uno.UI.RemoteControl.DevServer.Tests.Telemetry; @@ -24,7 +25,7 @@ public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() var telemetryFilePath = Path.Combine(tempDir, telemetryFileName); // Arrange - Creation of a solution is required for processors discovery - await solution.CreateSolutionFile(); + await solution.CreateSolutionFileAsync(); // Use DevServerTestHelper to start a server with telemetry redirection await using var helper = CreateTelemetryHelperWithExactPath(telemetryFilePath, solutionPath: solution.SolutionFile); @@ -43,34 +44,30 @@ public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() ); await client.SendMessage(new KeepAliveMessage()); - // Locate the test processor DLL path - // Look in the common build output directories where our test processor will be built - + // Locate the test processor DLL path using ExternalDllDiscoveryHelper const string testProcessorName = "Uno.UI.RemoteControl.TestProcessor.dll"; - var testProcessorPath = Directory.EnumerateFiles( - path: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "Uno.UI.RemoteControl.TestProcessor", "bin"), - searchPattern: testProcessorName, - searchOption: SearchOption.AllDirectories) - .FirstOrDefault(); + var testProcessorPath = ExternalDllDiscoveryHelper.DiscoverExternalDllPath( + Logger, + typeof(DevServerTestHelper).Assembly, + projectName: "Uno.UI.RemoteControl.TestProcessor", + dllFileName: testProcessorName); - if (testProcessorPath == null) + if (string.IsNullOrWhiteSpace(testProcessorPath) || !File.Exists(testProcessorPath)) { Assert.Fail( $"Could not find test processor assembly. Make sure {testProcessorName} is built before running this test."); } - testProcessorPath = Path.GetFullPath(testProcessorPath); - // Send processor discovery message pointing directly to the test processor DLL Logger.LogInformation("Using test processor at: {Path}", testProcessorPath); - await client.SendMessage(new ProcessorsDiscovery(testProcessorPath)); + await client.SendMessage(new ProcessorsDiscovery(testProcessorPath, appInstanceId)); // Wait for processor discovery and telemetry logging to complete await Task.Delay(2000, CT); // Ensure graceful shutdown to flush telemetry - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); // Assert - Check telemetry file was created and contains expected events File.Exists(telemetryFilePath).Should().BeTrue("Telemetry file should be created"); @@ -92,17 +89,6 @@ public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() AssertHasEvent(events, "uno/dev-server/processor-discovery-start"); AssertHasEvent(events, "uno/dev-server/processor-discovery-complete"); - // Log all events for debugging - Logger.LogInformation("Found {Count} telemetry events:", events.Count); - foreach (var (prefix, json) in events.Take(20)) - { - if (json.RootElement.TryGetProperty("EventName", out var eventName)) - { - Logger.LogInformation(" - Prefix: {Prefix}, EventName: {EventName}", - prefix, eventName.GetString()); - } - } - // Our test processor should have generated an initialization event when it was created // This event name will have the prefix defined in the TestProcessor assembly var processorEvents = events.Where(e => @@ -128,14 +114,14 @@ public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() { Logger.LogError(ex, "Test failed with exception"); - Console.Error.WriteLine(helper.ConsoleOutput); + await Console.Error.WriteLineAsync(helper.ConsoleOutput); throw; } finally { // Cleanup - Stop the server and remove test files await helper.StopAsync(CT); - await CleanupTelemetryTest(helper, telemetryFilePath); + await CleanupTelemetryTestAsync(helper, telemetryFilePath); } } @@ -155,7 +141,7 @@ public async Task Telemetry_ServerHotReloadProcessor_ResolvesCorrectly() var telemetryFilePath = Path.Combine(tempDir, telemetryFileName); // Arrange - Creation of a solution is required for processors discovery - await solution.CreateSolutionFile(); + await solution.CreateSolutionFileAsync(); // Use DevServerTestHelper to start a server with telemetry redirection await using var helper = CreateTelemetryHelperWithExactPath(telemetryFilePath, solutionPath: solution.SolutionFile); @@ -194,13 +180,13 @@ public async Task Telemetry_ServerHotReloadProcessor_ResolvesCorrectly() // Send processor discovery message pointing to the HotReload processor DLL Logger.LogInformation("Using HotReload processor at: {Path}", hotReloadProcessorPath); - await client.SendMessage(new ProcessorsDiscovery(hotReloadProcessorPath)); + await client.SendMessage(new ProcessorsDiscovery(hotReloadProcessorPath, appInstanceId)); // Wait for processor discovery and telemetry logging to complete await Task.Delay(3000, CT); // Longer wait for HotReload processor // Ensure graceful shutdown to flush telemetry - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); // Assert - Check telemetry file was created and contains expected events File.Exists(telemetryFilePath).Should().BeTrue("Telemetry file should be created"); @@ -253,14 +239,14 @@ public async Task Telemetry_ServerHotReloadProcessor_ResolvesCorrectly() { Logger.LogError(ex, "ServerHotReloadProcessor test failed with exception"); - Console.Error.WriteLine(helper.ConsoleOutput); + await Console.Error.WriteLineAsync(helper.ConsoleOutput); throw; } finally { // Cleanup - Stop the server and remove test files await helper.StopAsync(CT); - await CleanupTelemetryTest(helper, telemetryFilePath); + await CleanupTelemetryTestAsync(helper, telemetryFilePath); } } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index cdc3291dcf99..6ac4c7a174c5 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -117,18 +117,18 @@ protected DevServerTestHelper CreateTelemetryHelperWithExactPath(string exactFil /// Runs a complete telemetry test cycle: start server, wait, shutdown. /// /// True if the server started successfully - protected async Task RunTelemetryTestCycle(DevServerTestHelper helper, int waitTimeMs = 2000) + protected async Task RunTelemetryTestCycleAsync(DevServerTestHelper helper, int waitTimeMs = 2000) { var started = await helper.StartAsync(CT); helper.EnsureStarted(); await Task.Delay(waitTimeMs, CT); - await helper.AttemptGracefulShutdown(CT); + await helper.AttemptGracefulShutdownAsync(CT); return started; } - protected async Task CleanupTelemetryTest(DevServerTestHelper helper, string tempDir, string filePattern) + protected async Task CleanupTelemetryTestAsync(DevServerTestHelper helper, string tempDir, string filePattern) { await helper.StopAsync(CT); @@ -140,7 +140,7 @@ protected async Task CleanupTelemetryTest(DevServerTestHelper helper, string tem } } - protected async Task CleanupTelemetryTest(DevServerTestHelper helper, string filePath) + protected async Task CleanupTelemetryTestAsync(DevServerTestHelper helper, string filePath) { await helper.StopAsync(CT); @@ -151,17 +151,6 @@ protected async Task CleanupTelemetryTest(DevServerTestHelper helper, string fil } } - protected async Task WaitFor(Func> test, CancellationToken ct, int interations = 5, - int timeBetweenIterationsInMs = 250) - { - for (var i = 0; i < interations; i++) - { - try { return await test(ct); } - catch { await Task.Delay(timeBetweenIterationsInMs, ct); if (i == interations - 1) throw; } - } - throw new InvalidOperationException(); - } - /// /// Helper method to wait for a client to connect to the server with improved diagnostics. /// @@ -217,7 +206,7 @@ protected static void AssertHasPrefix(List<(string Prefix, JsonDocument Json)> e { events.Any(e => e.Prefix == prefix).Should().BeTrue($"Should contain at least one event with prefix '{prefix}'"); } - + protected void WriteEventsList(List<(string Prefix, JsonDocument Json)> events) { TestContext!.WriteLine($"Found {events.Count} telemetry events:"); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index 99f34defa4a1..0efd953f9e43 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -14,6 +14,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index c6c2d5ea3fd2..7daeeee04427 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -258,7 +258,7 @@ private void ProcessIdeMessage(object? sender, IdeMessage message) this.Log().LogDebug("Received app launch register message from IDE, mvid={Mvid}, platform={Platform}, isDebug={IsDebug}", appLaunchRegisterIdeMessage.Mvid, appLaunchRegisterIdeMessage.Platform, appLaunchRegisterIdeMessage.IsDebug); } var monitor = _serviceProvider.GetRequiredService(); - + monitor.RegisterLaunch(appLaunchRegisterIdeMessage.Mvid, appLaunchRegisterIdeMessage.Platform, appLaunchRegisterIdeMessage.IsDebug); } else if (_processors.TryGetValue(message.Scope, out var processor)) @@ -308,7 +308,14 @@ private async Task ProcessAppLaunchFrame(Frame frame) case AppLaunchStep.Connected: if (_serviceProvider.GetService() is { } monitor2) { - monitor2.ReportConnection(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + var success = monitor2.ReportConnection(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + if (!success) + { + if (this.Log().IsEnabled(LogLevel.Error)) + { + this.Log().LogError("App Connected: MVID={Mvid} Platform={Platform} Debug={Debug} - Failed to report connected: APP LAUNCH NOT REGISTERED.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + } + } } break; } diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 14f6ade7ddf3..cdf88ee1a7ca 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -195,7 +195,7 @@ private void HandleTimeout(LaunchEvent launchEvent, Key key) /// The MVID of the root/head application being connected. /// The name of the platform from which the connection is reported. Cannot be null or empty. /// true if the connection is from a debug build; otherwise, false. - public void ReportConnection(Guid mvid, string platform, bool isDebug) + public bool ReportConnection(Guid mvid, string platform, bool isDebug) { ArgumentException.ThrowIfNullOrEmpty(platform); @@ -219,6 +219,7 @@ public void ReportConnection(Guid mvid, string platform, bool isDebug) try { _options.OnConnected?.Invoke(ev); + return true; } catch { @@ -226,6 +227,8 @@ public void ReportConnection(Guid mvid, string platform, bool isDebug) } } } + + return false; } /// From 5b8ed56d5cf2b4c9193c70767df88d6550e1382e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 4 Oct 2025 14:34:54 -0400 Subject: [PATCH 10/49] ci: Fix build --- .../RealAppLaunchIntegrationTests.cs | 15 +++- .../Helpers/SolutionHelper.cs | 76 +++++++++++++++++-- .../Telemetry/TelemetryTestBase.cs | 12 +-- .../IDEChannel/AppLaunchRegisterIdeMessage.cs | 2 +- .../AppLaunch/ApplicationLaunchMonitor.md | 17 +++-- 5 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index fd698edc134c..008bfcf354ef 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -18,7 +18,7 @@ public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished( { // PRE-ARRANGE: Create a real Uno solution file (will contain desktop project) var solution = SolutionHelper!; - await solution.CreateSolutionFileAsync(); + await solution.CreateSolutionFileAsync(copyGlobalJson: false, platforms: "desktop"); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_app_success")); await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); @@ -56,7 +56,17 @@ public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished( AssertHasEvent(events, "uno/dev-server/app-launch/connected"); helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); +#if DEBUG + await solution.ShowDotnetVersionAsync(); +#endif } +#if !DEBUG + catch + { + await solution.ShowDotnetVersionAsync(); + throw; + } +#endif finally { // Clean up app process if it's still running @@ -122,10 +132,11 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev // Build the project with devserver configuration so the generators create the right ServerEndpointAttribute // Using MSBuild properties directly to override any .csproj.user or Directory.Build.props values + // Explicitly targeting net9.0-desktop to avoid any multi-targeting issues with .NET 10 SDK var buildInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", + Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework net9.0-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs index 242d321fcada..fe9c0b314cce 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs @@ -8,16 +8,18 @@ namespace Uno.UI.RemoteControl.DevServer.Tests.Helpers; public class SolutionHelper : IDisposable { + private readonly TestContext _testContext; private readonly string _solutionFileName; private readonly string _tempFolder; public string TempFolder => _tempFolder; public string SolutionFile => Path.Combine(_tempFolder, _solutionFileName + ".sln"); - private bool isDisposed; + private bool _isDisposed; - public SolutionHelper(string solutionFileName = "MyApp") + public SolutionHelper(TestContext testContext, string solutionFileName = "MyApp") { + _testContext = testContext; _solutionFileName = solutionFileName; _tempFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); @@ -27,17 +29,22 @@ public SolutionHelper(string solutionFileName = "MyApp") } } - public async Task CreateSolutionFileAsync() + public async Task CreateSolutionFileAsync( + bool copyGlobalJson = false, + string platforms = "wasm,desktop") { - if (isDisposed) + if (_isDisposed) { throw new ObjectDisposedException(nameof(SolutionHelper)); } + var platformArgs = string.Join(" ", platforms.Split(',').Select(p => $"-platforms \"{p.Trim()}\"")); + var arguments = $"new unoapp -n {_solutionFileName} -o {_tempFolder} -preset \"recommended\" {platformArgs}"; + var startInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"new unoapp -n {_solutionFileName} -o {_tempFolder} -preset \"recommended\" -platforms \"wasm\" -platforms \"desktop\"", + Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -48,7 +55,45 @@ public async Task CreateSolutionFileAsync() var (exitCode, output) = await ProcessUtil.RunProcessAsync(startInfo); if (exitCode != 0) { - throw new InvalidOperationException($"dotnet new unoapp failed with exit code {exitCode} / {output}"); + throw new InvalidOperationException($"dotnet new unoapp failed with exit code {exitCode} / {output}.\n>dotnet {arguments}"); + } + + // Optionally copy the global.json from the Uno repo to the temp folder to ensure we use the correct SDK version + // This is critical for CI environments where .NET 10 prerelease might be installed + if (copyGlobalJson) + { + CopyGlobalJsonToTempFolder(); + } + } + + private void CopyGlobalJsonToTempFolder() + { + // Find the global.json in the Uno repo (walking up from the current assembly location) + var assemblyLocation = Path.GetDirectoryName(typeof(SolutionHelper).Assembly.Location)!; + var currentDir = assemblyLocation; + string? globalJsonPath = null; + + // Walk up the directory tree to find global.json + while (currentDir != null) + { + var candidatePath = Path.Combine(currentDir, "global.json"); + if (File.Exists(candidatePath)) + { + globalJsonPath = candidatePath; + break; + } + currentDir = Path.GetDirectoryName(currentDir); + } + + if (globalJsonPath != null) + { + var targetPath = Path.Combine(_tempFolder, "global.json"); + File.Copy(globalJsonPath, targetPath, overwrite: true); + Console.WriteLine($"[DEBUG_LOG] Copied global.json from {globalJsonPath} to {targetPath}"); + } + else + { + Console.WriteLine("[DEBUG_LOG] Warning: Could not find global.json in parent directories"); } } @@ -129,9 +174,26 @@ public void EnsureUnoTemplatesInstalled() } } + public async Task ShowDotnetVersionAsync() + { + var startInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "--info", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = _tempFolder, + }; + + var (exitCode, output) = await ProcessUtil.RunProcessAsync(startInfo); + _testContext.WriteLine($"dotnet --info output:\n{output}"); + } + public void Dispose() { - isDisposed = true; + _isDisposed = true; // Force delete temp folder Directory.Delete(_tempFolder, true); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index 6ac4c7a174c5..10718513f950 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -38,7 +38,7 @@ private CancellationToken GetTimeoutToken() [TestInitialize] public void TestInitialize() { - SolutionHelper = new SolutionHelper(); + SolutionHelper = new SolutionHelper(TestContext!); SolutionHelper.EnsureUnoTemplatesInstalled(); } @@ -65,16 +65,6 @@ protected static void GlobalClassInitialize(TestContext context) where T : cl InitializeLogger(); } - private static void InitializeLogger(Type type) - { - var loggerFactory = LoggerFactory.Create(builder => - { - builder.AddConsole(); - builder.AddDebug(); - }); - Logger = loggerFactory.CreateLogger(type); - } - /// /// Creates a DevServerTestHelper with telemetry redirection to a temporary file. /// diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs index a9520edcf0a4..2c5137d6ac3a 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs @@ -3,7 +3,7 @@ namespace Uno.UI.RemoteControl.Messaging.IdeChannel; /// /// Message sent by the IDE to indicate that a target application has just been launched. -/// The dev-server will correlate this registration with a later runtime connection (AppIdentityMessage) using the MVID. +/// The dev-server will correlate this registration with a later runtime connection (`AppLaunchMessage` with `Step = Connected`) using the MVID, Platform and IsDebug. /// /// The MVID (Module Version ID) of the head application assembly. /// The target platform (case-sensitive, e.g. "Wasm", "Android"). diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md index bb89f256276c..8c53443d4258 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md @@ -5,7 +5,7 @@ - You tell it that an app was launched, then you report when a matching app connects. It matches them 1:1 in launch order and handles timeouts. - When a launched application fails to connect, it is reported as a timeout thru the OnTimeout callback. - It is thread-safe / Disposable. -- It used the MVID as the key. This is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid +- It uses a composite key: MVID + Platform + IsDebug. The MVID is the _Module Version ID_ of the app (head) assembly, which is unique per build. More info: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.module.moduleversionid ## How to use ### 1) Create the monitor (optionally with callbacks and a custom timeout): @@ -54,13 +54,18 @@ When integrated in the dev-server, the monitor emits telemetry events (prefix `u `latencyMs` is the elapsed time between registration and connection, measured internally. `timeoutSeconds` equals the configured timeout. -## IDE / Runtime Messages -To enable correlation end-to-end, two messages flow through the system: +## Integration points (IDE, WebSocket, HTTP) +The dev-server can receive registration and connection events through multiple channels: -1. IDE → DevServer: `AppLaunchRegisterIdeMessage` (scope: `AppLaunch`) carrying MVID, Platform, IsDebug. Triggers `RegisterLaunch`. -2. Runtime → DevServer: `AppIdentityMessage` (scope: `RemoteControlServer`) carrying MVID, Platform, IsDebug, automatically sent after WebSocket connection. Triggers `ReportConnection`. +- IDE → DevServer: `AppLaunchRegisterIdeMessage` (scope: `AppLaunch`) carrying MVID, Platform, IsDebug. Triggers `RegisterLaunch`. +- Runtime → DevServer over WebSocket (scope: `DevServerChannel`): `AppLaunchMessage` with `Step = Launched`. Triggers `RegisterLaunch`. +- HTTP GET → DevServer: `GET /applaunch/{mvid}?platform={platform}&isDebug={true|false}`. Triggers `RegisterLaunch`. -If no matching `AppIdentityMessage` arrives before timeout, a timeout event is emitted. +Connections are reported by the runtime after establishing the WebSocket connection: + +- Runtime → DevServer over WebSocket (scope: `DevServerChannel`): `AppLaunchMessage` with `Step = Connected`. Triggers `ReportConnection`. + +If no matching `Connected` message arrives before timeout, a timeout event is emitted. ### Testing / Time control From 290ff58308a704e46f55ba00da2b9112da954681 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sat, 4 Oct 2025 16:02:37 -0400 Subject: [PATCH 11/49] ci: Another try to fix build (net9 vs net10) --- .../RealAppLaunchIntegrationTests.cs | 12 ++--- .../Helpers/SolutionHelper.cs | 45 ++----------------- 2 files changed, 11 insertions(+), 46 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 008bfcf354ef..63bef3f677ca 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -10,6 +10,8 @@ namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; [TestClass] public class RealAppLaunchIntegrationTests : TelemetryTestBase { + private const string? _targetFramework = "net9.0"; + [ClassInitialize] public static void ClassInitialize(TestContext context) => GlobalClassInitialize(context); @@ -18,7 +20,7 @@ public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished( { // PRE-ARRANGE: Create a real Uno solution file (will contain desktop project) var solution = SolutionHelper!; - await solution.CreateSolutionFileAsync(copyGlobalJson: false, platforms: "desktop"); + await solution.CreateSolutionFileAsync(platforms: "desktop", targetFramework: _targetFramework); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_app_success")); await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); @@ -96,7 +98,7 @@ private async Task RegisterAppLaunchAsync(string projectPath, int httpPort) { var projectDir = Path.GetDirectoryName(projectPath)!; var assemblyName = Path.GetFileNameWithoutExtension(projectPath); - var tfm = "net9.0-desktop"; + var tfm = $"{_targetFramework}-desktop"; var assemblyPath = Path.Combine(projectDir, "bin", "Debug", tfm, assemblyName + ".dll"); TestContext!.WriteLine($"Reading assembly info from: {assemblyPath}"); @@ -132,11 +134,11 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev // Build the project with devserver configuration so the generators create the right ServerEndpointAttribute // Using MSBuild properties directly to override any .csproj.user or Directory.Build.props values - // Explicitly targeting net9.0-desktop to avoid any multi-targeting issues with .NET 10 SDK + // Explicitly targeting the detected framework (net10.0 on CI, net9.0 locally) var buildInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework net9.0-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", + Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework {_targetFramework}-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -165,7 +167,7 @@ private async Task StartSkiaDesktopAppAsync(string projectPath, int dev try { var projectDir = Path.GetDirectoryName(projectPath)!; - var appTfm = "net9.0-desktop"; + var appTfm = $"{_targetFramework}-desktop"; var appOutputDir = Path.Combine(projectDir, "bin", "Debug", appTfm); var freshRcDll = typeof(Uno.UI.RemoteControl.RemoteControlClient).Assembly.Location; var destRcDll = Path.Combine(appOutputDir, Path.GetFileName(freshRcDll)); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs index fe9c0b314cce..35de950103f3 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs @@ -30,8 +30,8 @@ public SolutionHelper(TestContext testContext, string solutionFileName = "MyApp" } public async Task CreateSolutionFileAsync( - bool copyGlobalJson = false, - string platforms = "wasm,desktop") + string platforms = "wasm,desktop", + string? targetFramework = null) { if (_isDisposed) { @@ -39,7 +39,8 @@ public async Task CreateSolutionFileAsync( } var platformArgs = string.Join(" ", platforms.Split(',').Select(p => $"-platforms \"{p.Trim()}\"")); - var arguments = $"new unoapp -n {_solutionFileName} -o {_tempFolder} -preset \"recommended\" {platformArgs}"; + var tfmArg = targetFramework != null ? $"-tfm \"{targetFramework}\"" : ""; + var arguments = $"new unoapp -n {_solutionFileName} -o {_tempFolder} -preset \"recommended\" {platformArgs} {tfmArg}".Trim(); var startInfo = new ProcessStartInfo { @@ -57,44 +58,6 @@ public async Task CreateSolutionFileAsync( { throw new InvalidOperationException($"dotnet new unoapp failed with exit code {exitCode} / {output}.\n>dotnet {arguments}"); } - - // Optionally copy the global.json from the Uno repo to the temp folder to ensure we use the correct SDK version - // This is critical for CI environments where .NET 10 prerelease might be installed - if (copyGlobalJson) - { - CopyGlobalJsonToTempFolder(); - } - } - - private void CopyGlobalJsonToTempFolder() - { - // Find the global.json in the Uno repo (walking up from the current assembly location) - var assemblyLocation = Path.GetDirectoryName(typeof(SolutionHelper).Assembly.Location)!; - var currentDir = assemblyLocation; - string? globalJsonPath = null; - - // Walk up the directory tree to find global.json - while (currentDir != null) - { - var candidatePath = Path.Combine(currentDir, "global.json"); - if (File.Exists(candidatePath)) - { - globalJsonPath = candidatePath; - break; - } - currentDir = Path.GetDirectoryName(currentDir); - } - - if (globalJsonPath != null) - { - var targetPath = Path.Combine(_tempFolder, "global.json"); - File.Copy(globalJsonPath, targetPath, overwrite: true); - Console.WriteLine($"[DEBUG_LOG] Copied global.json from {globalJsonPath} to {targetPath}"); - } - else - { - Console.WriteLine("[DEBUG_LOG] Warning: Could not find global.json in parent directories"); - } } private static object _lock = new(); From b6905e611088cab3951ca9348f3a8def6cecd9bf Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Oct 2025 22:37:54 -0400 Subject: [PATCH 12/49] feat(app-launch): Add scavenging for stale launches and enhance connection tracking - Introduced scavenging mechanism in `ApplicationLaunchMonitor` to remove stale launch entries beyond the retention period. --- .../Given_ApplicationLaunchMonitor.cs | 172 ++++++++++++++++-- .../AppLaunch/ApplicationLaunchMonitor.cs | 172 ++++++++++++------ .../Helpers/ServiceCollectionExtensions.cs | 3 +- 3 files changed, 276 insertions(+), 71 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index bf3bdb25a683..7e852d1a98fc 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -11,25 +11,36 @@ private static ApplicationLaunchMonitor CreateMonitor( out List registered, out List timeouts, out List connections, - TimeSpan? timeout = null) + out List connectionWasTimedOut, + TimeSpan? timeout = null, + TimeSpan? retention = null, + TimeSpan? scavengeInterval = null) { // Use local lists inside callbacks, then assign them to out parameters var registeredList = new List(); var timeoutsList = new List(); var connectionsList = new List(); + var connectionsTimedOutList = new List(); var options = new ApplicationLaunchMonitor.Options { Timeout = timeout ?? TimeSpan.FromSeconds(10), OnRegistered = e => registeredList.Add(e), OnTimeout = e => timeoutsList.Add(e), - OnConnected = e => connectionsList.Add(e), + OnConnected = (e, wasTimedOut) => + { + connectionsList.Add(e); + connectionsTimedOutList.Add(wasTimedOut); + }, + Retention = retention ?? TimeSpan.FromHours(1), + ScavengeInterval = scavengeInterval ?? TimeSpan.FromMinutes(5), }; clock = new FakeTimeProvider(DateTimeOffset.UtcNow); registered = registeredList; timeouts = timeoutsList; connections = connectionsList; + connectionWasTimedOut = connectionsTimedOutList; return new ApplicationLaunchMonitor(clock, options); } @@ -38,8 +49,13 @@ private static ApplicationLaunchMonitor CreateMonitor( public void WhenLaunchRegisteredAndNotTimedOut_ThenRegisteredCallbackOnly() { // Arrange - using var sut = CreateMonitor(out var clock, out var registered, out var timeouts, out var connections, - TimeSpan.FromSeconds(10)); + using var sut = CreateMonitor( + out var clock, + out var registered, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromSeconds(10)); var mvid = Guid.NewGuid(); // Act @@ -56,7 +72,12 @@ public void WhenLaunchRegisteredAndNotTimedOut_ThenRegisteredCallbackOnly() public void WhenMatchingConnectionReportedForRegisteredLaunch_ThenConnectedInvokedOnceAndConsumed() { // Arrange - using var sut = CreateMonitor(out var clock, out var registered, out var timeouts, out var connections); + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _); var mvid = Guid.NewGuid(); sut.RegisterLaunch(mvid, "Wasm", isDebug: true); @@ -77,7 +98,13 @@ public void WhenMatchingConnectionReportedForRegisteredLaunch_ThenConnectedInvok public void WhenConnectionsArriveForMultipleRegistrations_ThenFifoOrderIsPreserved() { // Arrange: ensure timeouts can expire earlier registrations and FIFO is preserved for active ones - using var sut = CreateMonitor(out var clock, out _, out var timeouts, out var connections, TimeSpan.FromMilliseconds(500)); + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromMilliseconds(500)); var mvid = Guid.NewGuid(); // First registration - will be expired @@ -108,7 +135,13 @@ public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stres const int K = 9000; // number to expire const int L = 1000; // number to remain active and validate FIFO on - using var sut = CreateMonitor(out var clock, out _, out var timeouts, out var connections, TimeSpan.FromMilliseconds(100)); + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromMilliseconds(100)); var mvid = Guid.NewGuid(); // Register K entries which will be expired @@ -149,7 +182,13 @@ public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stres public void WhenRegisteredLaunchTimeoutExpires_ThenTimeoutCallbackInvoked() { // Arrange - using var sut = CreateMonitor(out var clock, out _, out var timeouts, out _, TimeSpan.FromSeconds(10)); + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out _, + out _, + timeout: TimeSpan.FromSeconds(10)); var mvid = Guid.NewGuid(); sut.RegisterLaunch(mvid, "Wasm", isDebug: false); @@ -164,7 +203,12 @@ public void WhenRegisteredLaunchTimeoutExpires_ThenTimeoutCallbackInvoked() public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemoved() { // Arrange - using var sut = CreateMonitor(out var clock, out _, out var timeouts, out var connections); + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _); var mvid = Guid.NewGuid(); sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // will expire clock.Advance(TimeSpan.FromSeconds(5)); @@ -183,7 +227,7 @@ public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemove [TestMethod] public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentException() { - using var sut = CreateMonitor(out var clock, out _, out _, out _); + using var sut = CreateMonitor(out _, out _, out _, out _, out _); var mvid = Guid.NewGuid(); sut.Invoking(m => m.RegisterLaunch(mvid, null!, true)).Should().Throw(); @@ -195,7 +239,7 @@ public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentExcept [TestMethod] public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() { - using var sut = CreateMonitor(out var clock, out _, out _, out var connections); + using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); var mvid = Guid.NewGuid(); sut.RegisterLaunch(mvid, "Wasm", true); clock.Advance(TimeSpan.FromSeconds(1)); // bellow timeout @@ -204,5 +248,111 @@ public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() connections.Should().BeEmpty(); } + + [TestMethod] + public void ReportConnection_ReturnsBooleanIndicatingMatch() + { + using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); + var mvid = Guid.NewGuid(); + // No registration yet -> false + sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); + + // Register and then report -> true + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeTrue(); + + // Already consumed -> false + sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); + } + + [TestMethod] + public void WhenConnectionArrivesAfterTimeout_ThenStillConnects() + { + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromSeconds(5)); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // Advance past timeout so OnTimeout is invoked + clock.Advance(TimeSpan.FromSeconds(6)); + timeouts.Should().HaveCount(1); + + // Even though it timed out, a later connection should still be accepted + var result = sut.ReportConnection(mvid, "Wasm", isDebug: false); + result.Should().BeTrue(); + connections.Should().HaveCount(1); + // OnTimeout should still have been invoked + timeouts.Should().HaveCount(1); + } + + [TestMethod] + public void WhenScavengerRunsRepeatedly_ThenVeryOldEntriesAreRemoved() + { + // Arrange: Timeout long, retention short, scavenge frequent + using var sut = CreateMonitor( + out var clock, + out _, + out _, + out var connections, + out _, + timeout: TimeSpan.FromMinutes(10), + retention: TimeSpan.FromSeconds(2), + scavengeInterval: TimeSpan.FromSeconds(1)); + + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // Advance time past retention and run multiple scavenge intervals to ensure the periodic scavenge executed more than once + clock.Advance(TimeSpan.FromSeconds(3)); // now > retention + clock.Advance(TimeSpan.FromSeconds(1)); + clock.Advance(TimeSpan.FromSeconds(1)); + + // Act: after scavenging, the entry should be removed and cannot be connected + var result = sut.ReportConnection(mvid, "Wasm", isDebug: false); + result.Should().BeFalse(); + connections.Should().BeEmpty(); + } + + [TestMethod] + public void WhenScavengerRunsMultipleTimes_ThenOnlyOldEntriesRemovedAndNewerRemain() + { + using var sut = CreateMonitor( + out var clock, + out _, + out _, + out var connections, + out _, + timeout: TimeSpan.FromMinutes(10), + retention: TimeSpan.FromSeconds(2), + scavengeInterval: TimeSpan.FromSeconds(1)); + + var mvid = Guid.NewGuid(); + + // Register first entry + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromSeconds(1)); + + // Register second entry (newer) + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // We only advance enough time so multiple scavenge passes run (scavenge interval = 1s) + // but the newer entry (registered 1s after the first) is still within the retention window. + // Advance two seconds so scavenge runs (at t=2 and earlier), but newer entry (registered at t=1) + // remains within the retention window (cutoff = now - 2s => 0s, older entry removed, newer kept). + clock.Advance(TimeSpan.FromSeconds(2)); + + // Act: reporting twice should yield a single match (the newer entry), then no more + var first = sut.ReportConnection(mvid, "Wasm", isDebug: false); + var second = sut.ReportConnection(mvid, "Wasm", isDebug: false); + + first.Should().BeTrue(); + second.Should().BeFalse(); + connections.Should().HaveCount(1); + } } } diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index cdf88ee1a7ca..319f52b3c1de 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -36,8 +36,23 @@ public class Options /// /// Callback invoked when a registered application successfully connected. + /// The boolean parameter indicates whether the launch previously timed out (OnTimeout was invoked) + /// before the connection. /// - public Action? OnConnected { get; set; } + public Action? OnConnected { get; set; } + + /// + /// How long to retain launch entries before scavenging them from internal storage. + /// This is independent from which only triggers the OnTimeout callback. + /// Default: 24 hours. + /// + public TimeSpan Retention { get; set; } = TimeSpan.FromHours(1); + + /// + /// How often the monitor runs a scavenging pass to remove very old entries. + /// Default: 1 minute. + /// + public TimeSpan ScavengeInterval { get; set; } = TimeSpan.FromMinutes(5); } /// @@ -56,6 +71,12 @@ public sealed record LaunchEvent(Guid Mvid, string Platform, bool IsDebug, DateT // Track timeout timers for each launch event private readonly ConcurrentDictionary _timeoutTasks = new(); + // Track whether a given launch event has previously timed out + private readonly ConcurrentDictionary _timedOut = new(); + + // Periodic scavenger timer (removes very old entries beyond Retention) + private readonly IDisposable? _scavengeTimer; + /// /// Creates a new instance of . /// @@ -65,6 +86,21 @@ public ApplicationLaunchMonitor(TimeProvider? timeProvider = null, Options? opti { _timeProvider = timeProvider ?? TimeProvider.System; _options = options ?? new Options(); + + // Start periodic scavenger to remove very old entries. Use a periodic timer via TimeProvider so tests can control it. + try + { + _scavengeTimer = _timeProvider.CreateTimer( + static s => ((ApplicationLaunchMonitor)s!).RunScavengePass(), + this, + _options.ScavengeInterval, + _options.ScavengeInterval); + } + catch + { + // best-effort: if timer creation isn't supported, the monitor will still function but won't scavenge automatically. + _scavengeTimer = null; + } } /// @@ -141,94 +177,111 @@ private void ScheduleTimeout(LaunchEvent launchEvent, Key key) /// The key for the launch event. private void HandleTimeout(LaunchEvent launchEvent, Key key) { - // Remove the timed out event from the pending queue - if (_pending.TryGetValue(key, out var queue)) + // Instead of removing the timed-out event from pending, we keep it available for future connections + // but still invoke the OnTimeout callback to inform listeners that the timeout elapsed. + // Record the timed-out state so ReportConnection can notify callers that this event previously timed out. + _timedOut[launchEvent] = true; + try { - var tempQueue = new List(); - LaunchEvent? removedEvent = null; + _options.OnTimeout?.Invoke(launchEvent); + } + catch + { + // swallow + } + } - // Collect all items except the one that timed out - while (queue.TryDequeue(out var ev)) - { - if (ev.Equals(launchEvent) && removedEvent == null) - { - removedEvent = ev; - } - else - { - tempQueue.Add(ev); - } - } + /// + /// Reports an application successfully connecting back to development server. + /// If a matching registered launch exists, it consumes the oldest registration and the OnConnected callback is invoked for it. + /// Cancels the timeout task for the connected launch. + /// + /// The MVID of the root/head application being connected. + /// The name of the platform from which the connection is reported. Cannot be null or empty. + /// true if the connection is from a debug build; otherwise, false. + public bool ReportConnection(Guid mvid, string platform, bool isDebug) + { + ArgumentException.ThrowIfNullOrEmpty(platform); - // Put back the non-timed-out events - foreach (var ev in tempQueue) + var key = new Key(mvid, platform, isDebug); + // Try consume the oldest pending event if present. We prefer to dequeue an event that may have timed out + // previously (it's still in the queue). When consuming, cancel its timeout timer if any and invoke OnConnected. + if (_pending.TryGetValue(key, out var queue) && queue.TryDequeue(out var ev)) + { + // Cancel / dispose the timeout timer for this event if still present + if (_timeoutTasks.TryRemove(ev, out var timeoutTimer)) { - queue.Enqueue(ev); + try { timeoutTimer.Dispose(); } catch { } } - // If queue is empty, remove it + // If queue is now empty, remove it from dictionary if (queue.IsEmpty) { _pending.TryRemove(key, out _); } - // Invoke timeout callback for the removed event - if (removedEvent != null) + try { - try - { - _options.OnTimeout?.Invoke(removedEvent); - } - catch + var wasTimedOut = false; + if (_timedOut.TryRemove(ev, out var flag)) { - // swallow + wasTimedOut = flag; } + + _options.OnConnected?.Invoke(ev, wasTimedOut); + return true; + } + catch + { + // swallow } } + + return false; } /// - /// Reports an application successfully connecting back to development server. - /// If a matching registered launch exists, it consumes the oldest registration and the OnConnected callback is invoked for it. - /// Cancels the timeout task for the connected launch. + /// Runs a scavenging pass removing entries whose RegisteredAt is older than Retention. + /// This reclaims memory for entries that will never connect. /// - /// The MVID of the root/head application being connected. - /// The name of the platform from which the connection is reported. Cannot be null or empty. - /// true if the connection is from a debug build; otherwise, false. - public bool ReportConnection(Guid mvid, string platform, bool isDebug) + private void RunScavengePass() { - ArgumentException.ThrowIfNullOrEmpty(platform); - - var key = new Key(mvid, platform, isDebug); - if (_pending.TryGetValue(key, out var queue)) + var cutoff = _timeProvider.GetUtcNow() - _options.Retention; + // Clean pending queues + foreach (var kvp in _pending.ToArray()) { - if (queue.TryDequeue(out var ev)) + var key = kvp.Key; + var queue = kvp.Value; + var temp = new Queue(); + while (queue.TryDequeue(out var ev)) { - // Cancel / dispose the timeout timer for this event - if (_timeoutTasks.TryRemove(ev, out var timeoutTimer)) + if (ev.RegisteredAt >= cutoff) { - try { timeoutTimer.Dispose(); } catch { } + temp.Enqueue(ev); } - - // If queue is now empty, remove it from dictionary - if (queue.IsEmpty) + else { - _pending.TryRemove(key, out _); + // dispose any associated timeout task + if (_timeoutTasks.TryRemove(ev, out var t)) + { + try { t.Dispose(); } catch { } + } + // remove any record of timed-out state for scavenged items + _timedOut.TryRemove(ev, out _); } + } - try - { - _options.OnConnected?.Invoke(ev); - return true; - } - catch - { - // swallow - } + // re-enqueue remaining + while (temp.Count > 0) + { + queue.Enqueue(temp.Dequeue()); } - } - return false; + if (queue.IsEmpty) + { + _pending.TryRemove(key, out _); + } + } } /// @@ -245,6 +298,7 @@ public void Dispose() } _timeoutTasks.Clear(); + _timedOut.Clear(); _pending.Clear(); } } diff --git a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs index d1182c868b63..5a7c5d41905b 100644 --- a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs +++ b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs @@ -45,13 +45,14 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv ], null); }; - launchOptions.OnConnected = ev => + launchOptions.OnConnected = (ev, wasTimedOut) => { var latencyMs = (DateTimeOffset.UtcNow - ev.RegisteredAt).TotalMilliseconds; telemetry.TrackEvent("app-launch/connected", [ ("platform", ev.Platform), ("debug", ev.IsDebug.ToString()), + ("wasTimedOut", wasTimedOut.ToString()) ], [("latencyMs", latencyMs)]); }; From 30cc2fcc6a0087529c17f5ccfa381c3db0fb74fd Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Oct 2025 22:45:20 -0400 Subject: [PATCH 13/49] ci: Trying to fix tests on CI --- .../AppLaunch/RealAppLaunchIntegrationTests.cs | 2 +- .../Helpers/SolutionHelper.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 63bef3f677ca..583e24e85337 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -138,7 +138,7 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev var buildInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework {_targetFramework}-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", + Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework {_targetFramework}-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort} /bl:LogFile=\"{solutionDir}\\build-app.binlog\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs index 35de950103f3..dbc867571e16 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/SolutionHelper.cs @@ -89,7 +89,7 @@ public void EnsureUnoTemplatesInstalled() var installInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = "new install Uno.Templates::*-*", + Arguments = "new install Uno.Templates@*-*", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, From 3fd45508f0774383323cdcfc584bd4a346b0da18 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Oct 2025 23:12:53 -0400 Subject: [PATCH 14/49] fix(logging): Ensure consistent naming for binlog files and remove redundant build log argument --- .../AppLaunch/Given_ApplicationLaunchMonitor.cs | 2 +- .../AppLaunch/RealAppLaunchIntegrationTests.cs | 2 +- src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index 7e852d1a98fc..168fd432fbd7 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -99,7 +99,7 @@ public void WhenConnectionsArriveForMultipleRegistrations_ThenFifoOrderIsPreserv { // Arrange: ensure timeouts can expire earlier registrations and FIFO is preserved for active ones using var sut = CreateMonitor( - out var clock, + out var clock, out _, out var timeouts, out var connections, diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 583e24e85337..63bef3f677ca 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -138,7 +138,7 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev var buildInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework {_targetFramework}-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort} /bl:LogFile=\"{solutionDir}\\build-app.binlog\"", + Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework {_targetFramework}-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs index 7501332df7bb..eba4591517ff 100644 --- a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs +++ b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs @@ -49,7 +49,7 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te else { var binlog = Path.GetTempFileName(); - result = ProcessHelper.RunProcess("dotnet", DumpTFM($"\"-bl:{binlog}\""), wd); + result = ProcessHelper.RunProcess("dotnet", DumpTFM($"\"-bl:{binlog}.binlog\""), wd); _log.Log(LogLevel.Warning, msg); _log.Log(LogLevel.Debug, result.output); From 4d1995fa317d0b12548f98a731caa2b56e552337 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Oct 2025 23:13:57 -0400 Subject: [PATCH 15/49] chore: Temporary add output for failing test - will be removed --- src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs index eba4591517ff..a34f0b953312 100644 --- a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs +++ b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs @@ -35,6 +35,8 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te var result = ProcessHelper.RunProcess("dotnet", command, wd); var targetFrameworks = Read(tmp); + _log.Log(LogLevel.Warning, $"Temp output file content: {File.Exists(tmp) switch { true => string.Join(Environment.NewLine, File.ReadAllLines(tmp)), false => "" }}"); + if (targetFrameworks.IsEmpty) { if (_log.IsEnabled(LogLevel.Warning)) @@ -42,6 +44,10 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te var msg = $"Failed to get target frameworks of solution '{solutionFile}'. " + "This usually indicates that the solution is in an invalid state (e.g. a referenced project is missing on disk). " + $"Please fix and restart your IDE (command used: `dotnet {command}`)."; + + _log.Log(LogLevel.Warning, $"Command output: {result.output}"); + _log.Log(LogLevel.Warning, $"Error details: {result.error}"); + if (result.error is { Length: > 0 }) { _log.Log(LogLevel.Warning, new Exception(result.error), msg + " (cf. inner exception for more details.)"); @@ -52,7 +58,10 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te result = ProcessHelper.RunProcess("dotnet", DumpTFM($"\"-bl:{binlog}.binlog\""), wd); _log.Log(LogLevel.Warning, msg); - _log.Log(LogLevel.Debug, result.output); + if (result.error is { Length: > 0 }) + { + _log.Log(LogLevel.Warning, $"Error details: {result.error}"); + } } } From 37ce6411dbb5667202414168cf3d0fe30a9775a2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Sun, 5 Oct 2025 23:58:15 -0400 Subject: [PATCH 16/49] ci: Trying to fix the build again --- .../AppLaunch/RealAppLaunchIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 63bef3f677ca..4f158016e76e 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -138,7 +138,7 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev var buildInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal --framework {_targetFramework}-desktop -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", + Arguments = $"build \"{appProject}\" --configuration Debug --verbosity minimal -p:UnoRemoteControlHost=localhost -p:UnoRemoteControlPort={devServerPort}", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, From c0e15d3425de4fbdc64cecc1a43610561e4e7d4c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 9 Oct 2025 00:21:50 -0400 Subject: [PATCH 17/49] feat(app-launch): Introduce `VsAppLaunchStateService` for deterministic app launch tracking - Added a state machine to track application launches based on IDE events like Play and Build sequences. --- .../Given_VsAppLaunchStateService.cs | 309 ++++++++++++++++++ ...no.UI.RemoteControl.DevServer.Tests.csproj | 1 + .../AppLaunch/VsAppLaunchStateService.cs | 242 ++++++++++++++ .../AppLaunch/VsAppLaunchStateService.md | 53 +++ .../Uno.UI.RemoteControl.VS.csproj | 3 + 5 files changed, 608 insertions(+) create mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs create mode 100644 src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs create mode 100644 src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs new file mode 100644 index 000000000000..e2be002f789b --- /dev/null +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs @@ -0,0 +1,309 @@ +using Microsoft.Extensions.Time.Testing; +using Uno.UI.RemoteControl.VS.AppLaunch; + +namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; + +[TestClass] +public class Given_VsAppLaunchStateService +{ + private static VsAppLaunchStateService CreateService( + out FakeTimeProvider clock, + out List> events, + TimeSpan? window = null) + { + clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var evts = new List>(); + events = evts; + + var options = new VsAppLaunchStateService.Options + { + BuildWaitWindow = window ?? TimeSpan.FromSeconds(5) + }; + + var sut = new VsAppLaunchStateService(clock, options); + sut.StateChanged += (_, e) => evts.Add(e); + return sut; + } + + [TestMethod] + public void WhenStartAndNoBuildWithinWindow_ThenTimeoutAndIdle() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var stateDetails = Guid.NewGuid(); + + // Act + sut.Start(stateDetails); + + // Assert initial transition to PlayInvokedPendingBuild + events.Should().HaveCount(1); + events[0].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + + // Act: advance past the wait window to force timeout + clock.Advance(TimeSpan.FromSeconds(6)); + + // Assert: Expect PlayInvokedPendingBuild -> TimedOut -> Idle + events.Select(e => e.Current).Should() + .Equal( + VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild, + VsAppLaunchStateService.LaunchState.TimedOut, + VsAppLaunchStateService.LaunchState.Idle); + } + + [TestMethod] + public void WhenBuildBeginsAndSucceeds_ThenBuildSucceededAndIdle() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var stateDetails = Guid.NewGuid(); + + // Act + sut.Start(stateDetails); + + // Act: Notify build began within window + sut.NotifyBuild(stateDetails, BuildNotification.Began); + + // Assert + events.Select(e => e.Current).Should() + .Equal( + VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild, + VsAppLaunchStateService.LaunchState.BuildInProgress); + + // Act: Notify success -> BuildSucceeded then Idle + sut.NotifyBuild(stateDetails, BuildNotification.CompletedSuccess); + + // Assert sequence + events.Select(e => e.Current).Should() + .Equal( + VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild, + VsAppLaunchStateService.LaunchState.BuildInProgress, + VsAppLaunchStateService.LaunchState.BuildSucceeded, + VsAppLaunchStateService.LaunchState.Idle); + } + + [TestMethod] + public void WhenNotifyBuildForDifferentKey_ThenIgnored() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var stateDetails1 = Guid.NewGuid(); + var stateDetails2 = Guid.NewGuid(); + + // Act + sut.Start(stateDetails1); + + // Assert initial event emitted + events.Should().HaveCount(1); + + // Act: Notify build for another stateDetails -> ignored + sut.NotifyBuild(stateDetails2, BuildNotification.Began); + + // Assert no change + events.Should().HaveCount(1); + } + + [TestMethod] + public void WhenBuildCanceled_ThenResetsToIdle() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var stateDetails = "key42"; + + // Act + sut.Start(stateDetails); + + // Assert initial event + events.Should().HaveCount(1); + + // Act: cancel build + sut.NotifyBuild(stateDetails, BuildNotification.Canceled); + + // Assert that it reset to Idle + events.Select(e => e.Current).Should() + .Equal( + VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild, + VsAppLaunchStateService.LaunchState.Idle); + + events[0].StateDetails.Should().Be(stateDetails); + events[1].StateDetails.Should().BeNull(); // Idle should have no active StateDetails + } + [TestMethod] + public void WhenStartWithKeyThenChangeKey_BeforeBuild_ThenOldTimerCanceledAndNewKeyTimesOut() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var stateDetails1 = Guid.NewGuid(); + var stateDetails2 = Guid.NewGuid(); + + // Act: Start first cycle + sut.Start(stateDetails1); + + // Assert initial PlayInvokedPendingBuild + events.Should().HaveCount(1); + events[0].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + events[0].StateDetails.Should().Be(stateDetails1); + + // Act: advance partial time + clock.Advance(TimeSpan.FromSeconds(2)); + + // Act: Start a new cycle with a different stateDetails before build begins + sut.Start(stateDetails2); + + // Assert second PlayInvokedPendingBuild for new details + events.Should().HaveCount(2); + events[1].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + events[1].StateDetails.Should().Be(stateDetails2); + + // Act: Advance beyond the wait window to trigger only the latest timeout + clock.Advance(TimeSpan.FromSeconds(6)); + + // Assert exact sequence and that TimedOut has stateDetails2 and Idle clears details + events.Should().HaveCount(4); + events[0].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + events[0].StateDetails.Should().Be(stateDetails1); + events[1].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + events[1].StateDetails.Should().Be(stateDetails2); + events[2].Current.Should().Be(VsAppLaunchStateService.LaunchState.TimedOut); + events[2].StateDetails.Should().Be(stateDetails2); + events[3].Current.Should().Be(VsAppLaunchStateService.LaunchState.Idle); + events[3].StateDetails.Should().Be(Guid.Empty); + + // Act: Late notifications for the old details are ignored + sut.NotifyBuild(stateDetails1, BuildNotification.Began); + // Assert no new events + events.Should().HaveCount(4); + } + + [TestMethod] + public void WhenSuccessReportedWithoutBegan_ThenBuildSucceededThenIdleAndKeyCleared() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(3)); + var stateDetails = "k1"; + + // Act + sut.Start(stateDetails); + + // Assert initial + events.Should().HaveCount(1); + events[0].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + events[0].StateDetails.Should().Be(stateDetails); + + // Act: Report success out of the usual order (no Began) + sut.NotifyBuild(stateDetails, BuildNotification.CompletedSuccess); + + // Assert: PIPB -> BuildSucceeded -> Idle + events.Select(e => e.Current).Should() + .Equal( + VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild, + VsAppLaunchStateService.LaunchState.BuildSucceeded, + VsAppLaunchStateService.LaunchState.Idle); + + // Key should be present for success, then cleared on Idle + events[1].StateDetails.Should().Be(stateDetails); + events[2].StateDetails.Should().BeNull(); + } + + [TestMethod] + public void WhenTimeoutOccurs_ThenLaterNotificationsForThatKeyAreIgnored() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromMilliseconds(200)); + var stateDetails = Guid.NewGuid(); + + // Act + sut.Start(stateDetails); + + // Assert initial + events.Should().HaveCount(1); + + // Act: advance past timeout + clock.Advance(TimeSpan.FromMilliseconds(250)); + + // Assert timeout then idle + events.Select(e => e.Current).Should() + .Equal( + VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild, + VsAppLaunchStateService.LaunchState.TimedOut, + VsAppLaunchStateService.LaunchState.Idle); + + // Act: Now any notifications for the old details must be ignored + sut.NotifyBuild(stateDetails, BuildNotification.Canceled); + sut.NotifyBuild(stateDetails, BuildNotification.Began); + + // Assert no extra events + events.Should().HaveCount(3); + } + + [TestMethod] + public void WhenStart_ThenFirstNotify_TimeoutBeforeFirstNotify_ButNoTimeoutAfterBeganUntilNewStart() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromMilliseconds(200)); + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + + // Act: First cycle - start and let it timeout + sut.Start(first); + clock.Advance(TimeSpan.FromMilliseconds(250)); + + // Assert: first cycle timed out + events.Select(e => e.Current).Should() + .Contain(VsAppLaunchStateService.LaunchState.TimedOut); + + // Act: Second cycle - start and notify Began before window elapses + sut.Start(second); + sut.NotifyBuild(second, BuildNotification.Began); + + // Advance beyond the original window - there should be no TimedOut for the second cycle + clock.Advance(TimeSpan.FromMilliseconds(500)); + + // Assert: no additional TimedOut events for the second cycle. We count TimedOut occurrences and + // ensure only the first cycle produced one. + var timedOutCount = events.Count(e => e.Current == VsAppLaunchStateService.LaunchState.TimedOut); + timedOutCount.Should().Be(1); + + // Complete the second cycle to get back to Idle + sut.NotifyBuild(second, BuildNotification.CompletedSuccess); + events.Last().Current.Should().Be(VsAppLaunchStateService.LaunchState.Idle); + } + + [TestMethod] + public void Stress_ManyCycles_WithMixedOutcomes_NoLeakAndAlwaysIdleAtEnd() + { + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromMilliseconds(50)); + + const int iterations = 1_200; + for (var i = 0; i < iterations; i++) + { + var key = i; + sut.Start(key); + + var kind = i % 3; + if (kind == 0) + { + // Normal success path + sut.NotifyBuild(key, BuildNotification.Began); + sut.NotifyBuild(key, BuildNotification.CompletedSuccess); + } + else if (kind == 1) + { + // Let the timer expire for timeout + clock.Advance(TimeSpan.FromMilliseconds(60)); + } + else + { + // Cancel path + sut.NotifyBuild(key, BuildNotification.Canceled); + } + + // End-of-iteration assertions + sut.State.Should().Be(VsAppLaunchStateService.LaunchState.Idle); + events.Last().Current.Should().Be(VsAppLaunchStateService.LaunchState.Idle); + events.Last().StateDetails.Should().Be(0); + } + + // Basic sanity: we produced a lot of events but the final state is Idle with no active key + sut.State.Should().Be(VsAppLaunchStateService.LaunchState.Idle); + } +} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index 0efd953f9e43..61950a3f854d 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs new file mode 100644 index 000000000000..d49f7d7ff0cc --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Uno.UI.RemoteControl.VS.AppLaunch; + +/// +/// State machine that determines when an application is considered "launched" +/// based on a sequence of IDE-originating events (Play pressed, Build begins, Build completes). +/// +internal sealed class VsAppLaunchStateService : IDisposable +{ + /// + /// Configuration options for the state machine. + /// + public sealed class Options + { + /// + /// Time window during which a build is expected after Play is pressed. + /// Default is 5 seconds. + /// + public TimeSpan BuildWaitWindow { get; init; } = TimeSpan.FromSeconds(5); + } + + /// + /// High-level states for the launch tracking lifecycle. + /// + public enum LaunchState + { + /// + /// No active cycle is in progress. + /// + Idle, + + /// + /// The Play command was invoked and we are waiting briefly for a build to begin. + /// + PlayInvokedPendingBuild, + + /// + /// A solution build is currently running for this cycle. + /// + BuildInProgress, + + /// + /// The build completed successfully. In this service, this is a transient outcome that immediately returns to Idle. + /// + BuildSucceeded, + + /// + /// The build completed with errors. Transient outcome that immediately returns to Idle. + /// + BuildFailed, + + /// + /// No build began within the wait window after Play. Transient outcome that immediately returns to Idle. + /// + TimedOut + } + + private readonly TimeProvider _timeProvider; + private readonly Options _options; + private ITimer? _timer; + private static readonly IEqualityComparer _detailsComparer = EqualityComparer.Default; + + /// + /// Immutable snapshot of the current cycle and state. + /// Contains the correlation StateDetails and current high-level state. + /// + private record struct Snapshot(TStateDetails? StateDetails, LaunchState State); + + private Snapshot _snapshot = new Snapshot(default, LaunchState.Idle); + + /// + /// Current state of the state machine. + /// + public LaunchState State => _snapshot.State; + + /// + /// Single event raised on every state change. Includes the correlated StateDetails and basic context. + /// + public event EventHandler>? StateChanged; + + public VsAppLaunchStateService(TimeProvider? timeProvider = null, Options? options = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + _options = options ?? new Options(); + } + + /// + /// Signal a Play (Run) intent with correlation details chosen by the caller. + /// Starts a new cycle for these details and opens a short wait window for a build to begin. + /// + public void Start(TStateDetails details) + { + // Cancel any previous wait timer and initialize the new cycle for the provided details. + try { _timer?.Dispose(); } catch { } + _timer = null; + SetState(LaunchState.PlayInvokedPendingBuild, details); + ResetTimer(startNew: true); + } + + /// + /// Notify a build-related event for the given StateDetails. + /// Events for unrelated details are ignored. + /// + public void NotifyBuild(TStateDetails details, BuildNotification notification) + { + if (!_detailsComparer.Equals(details, _snapshot.StateDetails)) + { + return; // ignore events for other details + } + + switch (notification) + { + case BuildNotification.Began: + if (State == LaunchState.PlayInvokedPendingBuild) + { + SetState(LaunchState.BuildInProgress, details); + ResetTimer(startNew: false); + } + break; + + case BuildNotification.Canceled: + Reset(); + break; + + case BuildNotification.CompletedSuccess: + SetState(LaunchState.BuildSucceeded, details); + Reset(); + break; + + case BuildNotification.CompletedFailure: + SetState(LaunchState.BuildFailed, details); + Reset(); + break; + } + } + + + /// + /// Resets the state machine back to Idle and cancels any outstanding timers. + /// + public void Reset() + { + try { _timer?.Dispose(); } catch { } + _timer = null; + // Clear active StateDetails and emit Idle with no correlation details. + SetState(LaunchState.Idle, default); + } + + public void Dispose() => Reset(); + + private void SetState(LaunchState state, TStateDetails? details) + { + var prev = _snapshot.State; + var prevDetails = _snapshot.StateDetails; + if (prev == state && _detailsComparer.Equals(details, prevDetails)) + { + return; + } + + if (state == LaunchState.Idle) + { + // When transitioning back to Idle, set the snapshot to the provided details so listeners + // receive the correlated StateDetails for the cycle that just ended. Callers (Reset) + // may clear the snapshot afterwards. + _snapshot = new Snapshot(details, LaunchState.Idle); + } + else + { + _snapshot = new Snapshot(details, state); + } + + StateChanged?.Invoke(this, new StateChangedEventArgs(_timeProvider.GetUtcNow(), prev, state, _snapshot.StateDetails)); + } + + private void ResetTimer(bool startNew) + { + // Cancel any existing timer + try { _timer?.Dispose(); } catch { } + _timer = null; + + if (!startNew) + { + return; + } + + var capturedDetails = _snapshot.StateDetails; + + // Create a timer whose callback executes the timeout logic directly. + // Using the TimeProvider's CreateTimer allows FakeTimeProvider.Advance(...) in tests to + // synchronously trigger the callback, avoiding Task.Run/await boundaries and + // making tests deterministic. + ITimer? thisTimer = null; + thisTimer = _timeProvider.CreateTimer(state => + { + try + { + if (State == LaunchState.PlayInvokedPendingBuild && _detailsComparer.Equals(capturedDetails, _snapshot.StateDetails)) + { + SetState(LaunchState.TimedOut, capturedDetails); + Reset(); + } + } + finally + { + // Dispose only the timer instance that scheduled this callback. + try { thisTimer?.Dispose(); } catch { } + // Clear the field only if it still references this timer to avoid disposing a newer one. + if (object.ReferenceEquals(_timer, thisTimer)) + { + _timer = null; + } + } + }, null, dueTime: _options.BuildWaitWindow, period: Timeout.InfiniteTimeSpan); + _timer = thisTimer; + } +} + +internal enum BuildNotification +{ + Began, + Canceled, + CompletedSuccess, + CompletedFailure +} + +internal sealed class StateChangedEventArgs( + DateTimeOffset timestampUtc, + VsAppLaunchStateService.LaunchState previous, + VsAppLaunchStateService.LaunchState current, + TStateDetails? details) + : EventArgs +{ + public DateTimeOffset TimestampUtc { get; } = timestampUtc; + public VsAppLaunchStateService.LaunchState Previous { get; } = previous; + public VsAppLaunchStateService.LaunchState Current { get; } = current; + public TStateDetails? StateDetails { get; } = details; + public bool Succeeded => Current == VsAppLaunchStateService.LaunchState.BuildSucceeded; +} diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md new file mode 100644 index 000000000000..a121fc79867e --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md @@ -0,0 +1,53 @@ +# VsAppLaunchStateService + +A small, deterministic state machine that decides when a VS "Play" (Debug/Run) should be considered an application launch. It correlates a Play intent with the next build within a short time window and reports state transitions. + +## Highlights +- Generic correlation payload `TStateDetails` chosen by the caller (project path, Guid, tuple, etc.). +- Small API: `Start`, `NotifyBuild`, `Reset`. +- Single `StateChanged` event with the correlated `StateDetails` in the payload. +- Time is virtualizable via `System.TimeProvider` (polyfilled on net48) to make tests deterministic. + +## API +- `Start(TStateDetails details)`: mark a Play intent and open a short window for a build to begin. +- `NotifyBuild(TStateDetails details, BuildNotification notification)`: inform the state machine of build events for the same details. + - `BuildNotification`: `Began` | `Canceled` | `CompletedSuccess` | `CompletedFailure` +- `Reset()`: cancel the current wait/build and return to `Idle` (clears the correlated details). +- `event StateChanged(object sender, StateChangedEventArgs e)`: raised on every state transition with `TimestampUtc`, `Previous`, `Current`, `StateDetails`. The convenience property `e.Succeeded` is true only when `Current == BuildSucceeded`. + +## Options +- `Options.BuildWaitWindow` (default 5 seconds): time to wait after `Start` for a build to begin. + +## States +- `Idle` +- `PlayInvokedPendingBuild` +- `BuildInProgress` +- `BuildSucceeded` (transient) +- `BuildFailed` (transient) +- `TimedOut` (transient) + +Mermaid +```mermaid +stateDiagram-v2 + classDef transient stroke-dasharray: 5 5, font-style: italic + + [*] --> Idle + Idle --> PlayInvokedPendingBuild: Start(details) + + PlayInvokedPendingBuild --> BuildInProgress: NotifyBuild(details,Began) + PlayInvokedPendingBuild --> TimedOut: after BuildWaitWindow + + BuildInProgress --> BuildSucceeded: NotifyBuild(details,CompletedSuccess) + BuildInProgress --> BuildFailed: NotifyBuild(details,CompletedFailure) + BuildInProgress --> Idle: NotifyBuild(details,Canceled) + + BuildSucceeded --> Idle: auto + BuildFailed --> Idle: auto + TimedOut --> Idle: auto +``` + +## Notes +- Correlation: only events with the same `TStateDetails` affect the in‑flight cycle; others are ignored. +- Threading: events may be raised from a thread pool callback; marshal to UI if needed. +- Reset semantics: `Reset()` clears any active correlated `StateDetails` so `Idle` states are emitted without an active payload. +- Out-of-order completion: reporting `CompletedSuccess`/`CompletedFailure` is supported even if `Began` wasn't observed; the cycle transitions to the corresponding outcome and then `Idle`. diff --git a/src/Uno.UI.RemoteControl.VS/Uno.UI.RemoteControl.VS.csproj b/src/Uno.UI.RemoteControl.VS/Uno.UI.RemoteControl.VS.csproj index b1f888863476..b5635cdae6ae 100644 --- a/src/Uno.UI.RemoteControl.VS/Uno.UI.RemoteControl.VS.csproj +++ b/src/Uno.UI.RemoteControl.VS/Uno.UI.RemoteControl.VS.csproj @@ -42,6 +42,9 @@ + + + From f8e491f7abff284f7a6e709c16f8290a7543397d Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 9 Oct 2025 00:22:10 -0400 Subject: [PATCH 18/49] refactor(tests): Convert test namespace and class declarations to file-scoped --- .../Given_ApplicationLaunchMonitor.cs | 637 +++++++++--------- 1 file changed, 318 insertions(+), 319 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index 168fd432fbd7..db8d336b43bc 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -1,358 +1,357 @@ using Microsoft.Extensions.Time.Testing; using Uno.UI.RemoteControl.Server.AppLaunch; -namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch +namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; + +[TestClass] +public class Given_ApplicationLaunchMonitor { - [TestClass] - public class Given_ApplicationLaunchMonitor + private static ApplicationLaunchMonitor CreateMonitor( + out FakeTimeProvider clock, + out List registered, + out List timeouts, + out List connections, + out List connectionWasTimedOut, + TimeSpan? timeout = null, + TimeSpan? retention = null, + TimeSpan? scavengeInterval = null) { - private static ApplicationLaunchMonitor CreateMonitor( - out FakeTimeProvider clock, - out List registered, - out List timeouts, - out List connections, - out List connectionWasTimedOut, - TimeSpan? timeout = null, - TimeSpan? retention = null, - TimeSpan? scavengeInterval = null) - { - // Use local lists inside callbacks, then assign them to out parameters - var registeredList = new List(); - var timeoutsList = new List(); - var connectionsList = new List(); - var connectionsTimedOutList = new List(); - - var options = new ApplicationLaunchMonitor.Options - { - Timeout = timeout ?? TimeSpan.FromSeconds(10), - OnRegistered = e => registeredList.Add(e), - OnTimeout = e => timeoutsList.Add(e), - OnConnected = (e, wasTimedOut) => - { - connectionsList.Add(e); - connectionsTimedOutList.Add(wasTimedOut); - }, - Retention = retention ?? TimeSpan.FromHours(1), - ScavengeInterval = scavengeInterval ?? TimeSpan.FromMinutes(5), - }; - - clock = new FakeTimeProvider(DateTimeOffset.UtcNow); - registered = registeredList; - timeouts = timeoutsList; - connections = connectionsList; - connectionWasTimedOut = connectionsTimedOutList; - - return new ApplicationLaunchMonitor(clock, options); - } - - [TestMethod] - public void WhenLaunchRegisteredAndNotTimedOut_ThenRegisteredCallbackOnly() - { - // Arrange - using var sut = CreateMonitor( - out var clock, - out var registered, - out var timeouts, - out var connections, - out _, - timeout: TimeSpan.FromSeconds(10)); - var mvid = Guid.NewGuid(); - - // Act - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); - clock.Advance(TimeSpan.FromSeconds(5)); - - // Assert - registered.Should().HaveCount(1); - timeouts.Should().BeEmpty(); - connections.Should().BeEmpty(); - } + // Use local lists inside callbacks, then assign them to out parameters + var registeredList = new List(); + var timeoutsList = new List(); + var connectionsList = new List(); + var connectionsTimedOutList = new List(); - [TestMethod] - public void WhenMatchingConnectionReportedForRegisteredLaunch_ThenConnectedInvokedOnceAndConsumed() + var options = new ApplicationLaunchMonitor.Options { - // Arrange - using var sut = CreateMonitor( - out var clock, - out _, - out var timeouts, - out var connections, - out _); - var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); - - // Act - sut.ReportConnection(mvid, "Wasm", isDebug: true); - clock.Advance(TimeSpan.FromSeconds(500)); - - // Assert - connections.Should().HaveCount(1); - timeouts.Should().BeEmpty(); - - // And: second report should not match (consumed) - sut.ReportConnection(mvid, "Wasm", isDebug: true); - connections.Should().HaveCount(1); - } - - [TestMethod] - public void WhenConnectionsArriveForMultipleRegistrations_ThenFifoOrderIsPreserved() - { - // Arrange: ensure timeouts can expire earlier registrations and FIFO is preserved for active ones - using var sut = CreateMonitor( - out var clock, - out _, - out var timeouts, - out var connections, - out _, - timeout: TimeSpan.FromMilliseconds(500)); - var mvid = Guid.NewGuid(); - - // First registration - will be expired - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); - // Advance beyond timeout so the first one expires - clock.Advance(TimeSpan.FromMilliseconds(600)); - - // Two active registrations that should remain in FIFO order - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); - clock.Advance(TimeSpan.FromMilliseconds(1)); - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); - - // Act - sut.ReportConnection(mvid, "Wasm", isDebug: true); - sut.ReportConnection(mvid, "Wasm", isDebug: true); - - // Assert - // First registration should have timed out - timeouts.Should().HaveCount(1); - connections.Should().HaveCount(2); - connections[0].RegisteredAt.Should().BeOnOrBefore(connections[1].RegisteredAt); - } - - [TestMethod] - public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stress() - { - // Stress test: large number of registrations where many expire, then a batch of active registrations - const int K = 9000; // number to expire - const int L = 1000; // number to remain active and validate FIFO on - - using var sut = CreateMonitor( - out var clock, - out _, - out var timeouts, - out var connections, - out _, - timeout: TimeSpan.FromMilliseconds(100)); - var mvid = Guid.NewGuid(); - - // Register K entries which will be expired - for (var i = 0; i < K; i++) + Timeout = timeout ?? TimeSpan.FromSeconds(10), + OnRegistered = e => registeredList.Add(e), + OnTimeout = e => timeoutsList.Add(e), + OnConnected = (e, wasTimedOut) => { - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); - clock.Advance(TimeSpan.FromTicks(1)); - } + connectionsList.Add(e); + connectionsTimedOutList.Add(wasTimedOut); + }, + Retention = retention ?? TimeSpan.FromHours(1), + ScavengeInterval = scavengeInterval ?? TimeSpan.FromMinutes(5), + }; + + clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + registered = registeredList; + timeouts = timeoutsList; + connections = connectionsList; + connectionWasTimedOut = connectionsTimedOutList; + + return new ApplicationLaunchMonitor(clock, options); + } - // Advance to let those K entries time out - clock.Advance(TimeSpan.FromMilliseconds(150)); + [TestMethod] + public void WhenLaunchRegisteredAndNotTimedOut_ThenRegisteredCallbackOnly() + { + // Arrange + using var sut = CreateMonitor( + out var clock, + out var registered, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromSeconds(10)); + var mvid = Guid.NewGuid(); + + // Act + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + clock.Advance(TimeSpan.FromSeconds(5)); + + // Assert + registered.Should().HaveCount(1); + timeouts.Should().BeEmpty(); + connections.Should().BeEmpty(); + } - // Register L active entries without advancing the global clock so they remain within the timeout window - for (var i = 0; i < L; i++) - { - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); - clock.Advance(TimeSpan.FromTicks(1)); - // Do NOT advance the clock here: advancing would make early active entries expire before we report connections. - } + [TestMethod] + public void WhenMatchingConnectionReportedForRegisteredLaunch_ThenConnectedInvokedOnceAndConsumed() + { + // Arrange + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + + // Act + sut.ReportConnection(mvid, "Wasm", isDebug: true); + clock.Advance(TimeSpan.FromSeconds(500)); + + // Assert + connections.Should().HaveCount(1); + timeouts.Should().BeEmpty(); + + // And: second report should not match (consumed) + sut.ReportConnection(mvid, "Wasm", isDebug: true); + connections.Should().HaveCount(1); + } - // Act: report L times - for (var i = 0; i < L; i++) - { - sut.ReportConnection(mvid, "Wasm", isDebug: false); - clock.Advance(TimeSpan.FromTicks(1)); - } - - // Assert: at least K should have timed out, and we should have L connections in FIFO order - timeouts.Count.Should().BeGreaterThanOrEqualTo(K); - connections.Should().HaveCount(L); - for (var i = 1; i < connections.Count; i++) - { - connections[i - 1].RegisteredAt.Should().BeOnOrBefore(connections[i].RegisteredAt); - } - } + [TestMethod] + public void WhenConnectionsArriveForMultipleRegistrations_ThenFifoOrderIsPreserved() + { + // Arrange: ensure timeouts can expire earlier registrations and FIFO is preserved for active ones + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromMilliseconds(500)); + var mvid = Guid.NewGuid(); + + // First registration - will be expired + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + // Advance beyond timeout so the first one expires + clock.Advance(TimeSpan.FromMilliseconds(600)); + + // Two active registrations that should remain in FIFO order + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + clock.Advance(TimeSpan.FromMilliseconds(1)); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + + // Act + sut.ReportConnection(mvid, "Wasm", isDebug: true); + sut.ReportConnection(mvid, "Wasm", isDebug: true); + + // Assert + // First registration should have timed out + timeouts.Should().HaveCount(1); + connections.Should().HaveCount(2); + connections[0].RegisteredAt.Should().BeOnOrBefore(connections[1].RegisteredAt); + } - [TestMethod] - public void WhenRegisteredLaunchTimeoutExpires_ThenTimeoutCallbackInvoked() + [TestMethod] + public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stress() + { + // Stress test: large number of registrations where many expire, then a batch of active registrations + const int K = 9000; // number to expire + const int L = 1000; // number to remain active and validate FIFO on + + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromMilliseconds(100)); + var mvid = Guid.NewGuid(); + + // Register K entries which will be expired + for (var i = 0; i < K; i++) { - // Arrange - using var sut = CreateMonitor( - out var clock, - out _, - out var timeouts, - out _, - out _, - timeout: TimeSpan.FromSeconds(10)); - var mvid = Guid.NewGuid(); sut.RegisterLaunch(mvid, "Wasm", isDebug: false); - - // Act - clock.Advance(TimeSpan.FromSeconds(11)); - - // Assert - timeouts.Should().HaveCount(1); + clock.Advance(TimeSpan.FromTicks(1)); } - [TestMethod] - public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemoved() - { - // Arrange - using var sut = CreateMonitor( - out var clock, - out _, - out var timeouts, - out var connections, - out _); - var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // will expire - clock.Advance(TimeSpan.FromSeconds(5)); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // still active - - // Act - clock.Advance(TimeSpan.FromSeconds(6)); // first expired, second still active - - // Assert - timeouts.Should().HaveCount(1); - // Active one should still be connectable - sut.ReportConnection(mvid, "Wasm", isDebug: false); - connections.Should().HaveCount(1); - } + // Advance to let those K entries time out + clock.Advance(TimeSpan.FromMilliseconds(150)); - [TestMethod] - public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentException() + // Register L active entries without advancing the global clock so they remain within the timeout window + for (var i = 0; i < L; i++) { - using var sut = CreateMonitor(out _, out _, out _, out _, out _); - var mvid = Guid.NewGuid(); - - sut.Invoking(m => m.RegisterLaunch(mvid, null!, true)).Should().Throw(); - sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true)).Should().Throw(); - sut.Invoking(m => m.ReportConnection(mvid, null!, true)).Should().Throw(); - sut.Invoking(m => m.ReportConnection(mvid, string.Empty, true)).Should().Throw(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromTicks(1)); + // Do NOT advance the clock here: advancing would make early active entries expire before we report connections. } - [TestMethod] - public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() + // Act: report L times + for (var i = 0; i < L; i++) { - using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); - var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", true); - clock.Advance(TimeSpan.FromSeconds(1)); // bellow timeout - - sut.ReportConnection(mvid, "wasm", true); - - connections.Should().BeEmpty(); + sut.ReportConnection(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromTicks(1)); } - [TestMethod] - public void ReportConnection_ReturnsBooleanIndicatingMatch() + // Assert: at least K should have timed out, and we should have L connections in FIFO order + timeouts.Count.Should().BeGreaterThanOrEqualTo(K); + connections.Should().HaveCount(L); + for (var i = 1; i < connections.Count; i++) { - using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); - var mvid = Guid.NewGuid(); - // No registration yet -> false - sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); + connections[i - 1].RegisteredAt.Should().BeOnOrBefore(connections[i].RegisteredAt); + } + } - // Register and then report -> true - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); - sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeTrue(); + [TestMethod] + public void WhenRegisteredLaunchTimeoutExpires_ThenTimeoutCallbackInvoked() + { + // Arrange + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out _, + out _, + timeout: TimeSpan.FromSeconds(10)); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // Act + clock.Advance(TimeSpan.FromSeconds(11)); + + // Assert + timeouts.Should().HaveCount(1); + } - // Already consumed -> false - sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); - } + [TestMethod] + public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemoved() + { + // Arrange + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // will expire + clock.Advance(TimeSpan.FromSeconds(5)); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // still active + + // Act + clock.Advance(TimeSpan.FromSeconds(6)); // first expired, second still active + + // Assert + timeouts.Should().HaveCount(1); + // Active one should still be connectable + sut.ReportConnection(mvid, "Wasm", isDebug: false); + connections.Should().HaveCount(1); + } - [TestMethod] - public void WhenConnectionArrivesAfterTimeout_ThenStillConnects() - { - using var sut = CreateMonitor( - out var clock, - out _, - out var timeouts, - out var connections, - out _, - timeout: TimeSpan.FromSeconds(5)); - var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + [TestMethod] + public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentException() + { + using var sut = CreateMonitor(out _, out _, out _, out _, out _); + var mvid = Guid.NewGuid(); - // Advance past timeout so OnTimeout is invoked - clock.Advance(TimeSpan.FromSeconds(6)); - timeouts.Should().HaveCount(1); + sut.Invoking(m => m.RegisterLaunch(mvid, null!, true)).Should().Throw(); + sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true)).Should().Throw(); + sut.Invoking(m => m.ReportConnection(mvid, null!, true)).Should().Throw(); + sut.Invoking(m => m.ReportConnection(mvid, string.Empty, true)).Should().Throw(); + } - // Even though it timed out, a later connection should still be accepted - var result = sut.ReportConnection(mvid, "Wasm", isDebug: false); - result.Should().BeTrue(); - connections.Should().HaveCount(1); - // OnTimeout should still have been invoked - timeouts.Should().HaveCount(1); - } + [TestMethod] + public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() + { + using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", true); + clock.Advance(TimeSpan.FromSeconds(1)); // bellow timeout - [TestMethod] - public void WhenScavengerRunsRepeatedly_ThenVeryOldEntriesAreRemoved() - { - // Arrange: Timeout long, retention short, scavenge frequent - using var sut = CreateMonitor( - out var clock, - out _, - out _, - out var connections, - out _, - timeout: TimeSpan.FromMinutes(10), - retention: TimeSpan.FromSeconds(2), - scavengeInterval: TimeSpan.FromSeconds(1)); - - var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.ReportConnection(mvid, "wasm", true); - // Advance time past retention and run multiple scavenge intervals to ensure the periodic scavenge executed more than once - clock.Advance(TimeSpan.FromSeconds(3)); // now > retention - clock.Advance(TimeSpan.FromSeconds(1)); - clock.Advance(TimeSpan.FromSeconds(1)); + connections.Should().BeEmpty(); + } - // Act: after scavenging, the entry should be removed and cannot be connected - var result = sut.ReportConnection(mvid, "Wasm", isDebug: false); - result.Should().BeFalse(); - connections.Should().BeEmpty(); - } + [TestMethod] + public void ReportConnection_ReturnsBooleanIndicatingMatch() + { + using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); + var mvid = Guid.NewGuid(); + // No registration yet -> false + sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); - [TestMethod] - public void WhenScavengerRunsMultipleTimes_ThenOnlyOldEntriesRemovedAndNewerRemain() - { - using var sut = CreateMonitor( - out var clock, - out _, - out _, - out var connections, - out _, - timeout: TimeSpan.FromMinutes(10), - retention: TimeSpan.FromSeconds(2), - scavengeInterval: TimeSpan.FromSeconds(1)); - - var mvid = Guid.NewGuid(); - - // Register first entry - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); - clock.Advance(TimeSpan.FromSeconds(1)); + // Register and then report -> true + sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeTrue(); - // Register second entry (newer) - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + // Already consumed -> false + sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); + } - // We only advance enough time so multiple scavenge passes run (scavenge interval = 1s) - // but the newer entry (registered 1s after the first) is still within the retention window. - // Advance two seconds so scavenge runs (at t=2 and earlier), but newer entry (registered at t=1) - // remains within the retention window (cutoff = now - 2s => 0s, older entry removed, newer kept). - clock.Advance(TimeSpan.FromSeconds(2)); + [TestMethod] + public void WhenConnectionArrivesAfterTimeout_ThenStillConnects() + { + using var sut = CreateMonitor( + out var clock, + out _, + out var timeouts, + out var connections, + out _, + timeout: TimeSpan.FromSeconds(5)); + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // Advance past timeout so OnTimeout is invoked + clock.Advance(TimeSpan.FromSeconds(6)); + timeouts.Should().HaveCount(1); + + // Even though it timed out, a later connection should still be accepted + var result = sut.ReportConnection(mvid, "Wasm", isDebug: false); + result.Should().BeTrue(); + connections.Should().HaveCount(1); + // OnTimeout should still have been invoked + timeouts.Should().HaveCount(1); + } - // Act: reporting twice should yield a single match (the newer entry), then no more - var first = sut.ReportConnection(mvid, "Wasm", isDebug: false); - var second = sut.ReportConnection(mvid, "Wasm", isDebug: false); + [TestMethod] + public void WhenScavengerRunsRepeatedly_ThenVeryOldEntriesAreRemoved() + { + // Arrange: Timeout long, retention short, scavenge frequent + using var sut = CreateMonitor( + out var clock, + out _, + out _, + out var connections, + out _, + timeout: TimeSpan.FromMinutes(10), + retention: TimeSpan.FromSeconds(2), + scavengeInterval: TimeSpan.FromSeconds(1)); + + var mvid = Guid.NewGuid(); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // Advance time past retention and run multiple scavenge intervals to ensure the periodic scavenge executed more than once + clock.Advance(TimeSpan.FromSeconds(3)); // now > retention + clock.Advance(TimeSpan.FromSeconds(1)); + clock.Advance(TimeSpan.FromSeconds(1)); + + // Act: after scavenging, the entry should be removed and cannot be connected + var result = sut.ReportConnection(mvid, "Wasm", isDebug: false); + result.Should().BeFalse(); + connections.Should().BeEmpty(); + } - first.Should().BeTrue(); - second.Should().BeFalse(); - connections.Should().HaveCount(1); - } + [TestMethod] + public void WhenScavengerRunsMultipleTimes_ThenOnlyOldEntriesRemovedAndNewerRemain() + { + using var sut = CreateMonitor( + out var clock, + out _, + out _, + out var connections, + out _, + timeout: TimeSpan.FromMinutes(10), + retention: TimeSpan.FromSeconds(2), + scavengeInterval: TimeSpan.FromSeconds(1)); + + var mvid = Guid.NewGuid(); + + // Register first entry + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + clock.Advance(TimeSpan.FromSeconds(1)); + + // Register second entry (newer) + sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + + // We only advance enough time so multiple scavenge passes run (scavenge interval = 1s) + // but the newer entry (registered 1s after the first) is still within the retention window. + // Advance two seconds so scavenge runs (at t=2 and earlier), but newer entry (registered at t=1) + // remains within the retention window (cutoff = now - 2s => 0s, older entry removed, newer kept). + clock.Advance(TimeSpan.FromSeconds(2)); + + // Act: reporting twice should yield a single match (the newer entry), then no more + var first = sut.ReportConnection(mvid, "Wasm", isDebug: false); + var second = sut.ReportConnection(mvid, "Wasm", isDebug: false); + + first.Should().BeTrue(); + second.Should().BeFalse(); + connections.Should().HaveCount(1); } } From d429acfd94364a354317e73bc46619cabe26ad0f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 9 Oct 2025 22:18:05 -0400 Subject: [PATCH 19/49] feat(app-launch): Add `VsAppLaunchIdeBridge` to integrate Play/Build events with app launch tracking # Conflicts: # src/Uno.UI.RemoteControl.VS/EntryPoint.cs # Conflicts: # src/Uno.UI.RemoteControl.VS/EntryPoint.cs --- .../AppLaunch/AppLaunchDetails.cs | 7 + .../AppLaunch/VsAppLaunchIdeBridge.cs | 167 ++++++++++++++++++ .../AppLaunch/VsAppLaunchStateService.cs | 1 - src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 21 ++- 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs create mode 100644 src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs new file mode 100644 index 000000000000..b3c61723e493 --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs @@ -0,0 +1,7 @@ +namespace Uno.UI.RemoteControl.VS.AppLaunch; + +/// +/// Correlation details for a single application launch cycle. +/// For now we key on StartupProjectPath which is stable across the Play/Build sequence. +/// +internal readonly record struct AppLaunchDetails(string? StartupProjectPath); diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs new file mode 100644 index 000000000000..beada7b2ed6b --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs @@ -0,0 +1,167 @@ +using System; +using System.Threading.Tasks; +using EnvDTE; +using EnvDTE80; +using Microsoft; +using Microsoft.VisualStudio; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; + +namespace Uno.UI.RemoteControl.VS.AppLaunch; + +/// +/// Bridges IDE events (Play/Run command and solution build lifecycle) to the VsAppLaunchStateService. +/// +internal sealed class VsAppLaunchIdeBridge : IDisposable +{ + private readonly AsyncPackage _package; + private readonly DTE2 _dte; + private readonly VsAppLaunchStateService _stateService; + private IVsSolutionBuildManager2? _sbm; + private uint _adviseCookie; + private CommandEvents? _debugStart; + private CommandEvents? _runNoDebug; + private _dispCommandEvents_BeforeExecuteEventHandler? _beforeExecuteHandler; + + private VsAppLaunchIdeBridge(AsyncPackage package, DTE2 dte, VsAppLaunchStateService stateService) + { + _package = package; + _dte = dte; + _stateService = stateService; + } + + public static async Task CreateAsync(AsyncPackage package, DTE2 dte, VsAppLaunchStateService stateService) + { + var bridge = new VsAppLaunchIdeBridge(package, dte, stateService); + await bridge.InitializeAsync(); + return bridge; + } + + private async Task InitializeAsync() + { + _sbm = await _package.GetServiceAsync(); + Assumes.Present(_sbm); + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var sink = new BuildEventsSink(this); + _sbm.AdviseUpdateSolutionEvents(sink, out _adviseCookie); + + // VSStd97 command set (Play buttons) + const string std97 = "{5EFC7975-14BC-11CF-9B2B-00AA00573819}"; + + _debugStart = _dte.Events.CommandEvents[std97, (int)VSConstants.VSStd97CmdID.Start]; + _runNoDebug = _dte.Events.CommandEvents[std97, (int)VSConstants.VSStd97CmdID.StartNoDebug]; + + _beforeExecuteHandler = OnBeforeExecute; + _debugStart.BeforeExecute += _beforeExecuteHandler; + _runNoDebug.BeforeExecute += _beforeExecuteHandler; + } + + private void OnBeforeExecute(string guid, int id, object customIn, object customOut, ref bool cancelDefault) + { + // Fire-and-forget; collect details on UI thread when needed + _package.JoinableTaskFactory.RunAsync(async () => + { + var details = await CollectStartupInfoAsync(); + _stateService.Start(details); + }).FileAndForget("uno/appLaunch/onBeforeExecute"); + } + + private async Task CollectStartupInfoAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + string? projectPath = null; + IVsHierarchy? hierarchy = null; + + try + { + if (_sbm is not null) + { + _sbm.get_StartupProject(out hierarchy); + } + } + catch + { + // ignore + } + + if (hierarchy is not null + && hierarchy.GetProperty((uint)VSConstants.VSITEMID.Root, (int)__VSHPROPID.VSHPROPID_ExtObject, out object extObj) == VSConstants.S_OK + && extObj is Project project) + { + projectPath = project.FullName; + } + + return new AppLaunchDetails(projectPath); + } + + private sealed class BuildEventsSink(VsAppLaunchIdeBridge owner) : IVsUpdateSolutionEvents2 + { + public int UpdateSolution_Begin(ref int pfCancelUpdate) + { + ThreadHelper.ThrowIfNotOnUIThread(); + pfCancelUpdate = 0; + owner._package.JoinableTaskFactory.RunAsync(async () => + { + var details = await owner.CollectStartupInfoAsync(); + owner._stateService.NotifyBuild(details, BuildNotification.Began); + }).FileAndForget("uno/appLaunch/buildBegin"); + return VSConstants.S_OK; + } + + public int UpdateSolution_Done(int fSucceeded, int fModified, int fCancelCommand) + { + owner._package.JoinableTaskFactory.RunAsync(async () => + { + var details = await owner.CollectStartupInfoAsync(); + owner._stateService.NotifyBuild(details, fSucceeded != 0 ? BuildNotification.CompletedSuccess : BuildNotification.CompletedFailure); + }).FileAndForget("uno/appLaunch/buildDone"); + return VSConstants.S_OK; + } + + public int UpdateSolution_Cancel() + { + owner._package.JoinableTaskFactory.RunAsync(async () => + { + var details = await owner.CollectStartupInfoAsync(); + owner._stateService.NotifyBuild(details, BuildNotification.Canceled); + }).FileAndForget("uno/appLaunch/buildCancel"); + return VSConstants.S_OK; + } + + public int UpdateSolution_StartUpdate(ref int pfCancelUpdate) => VSConstants.S_OK; + public int UpdateProjectCfg_Begin(IVsHierarchy pHierProj, IVsCfg pCfgProj, IVsCfg pCfgSln, uint dwAction, ref int pfCancel) => VSConstants.S_OK; + public int UpdateProjectCfg_Done(IVsHierarchy pHierProj, IVsCfg pCfgProj, IVsCfg pCfgSln, uint dwAction, int fSuccess, int fCancel) => VSConstants.S_OK; + public int OnActiveProjectCfgChange(IVsHierarchy pIVsHierarchy) => VSConstants.S_OK; + } + + public void Dispose() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + if (_adviseCookie != 0 && _sbm is not null) + { + _sbm.UnadviseUpdateSolutionEvents(_adviseCookie); + _adviseCookie = 0; + } + } + catch { } + + try + { + if (_debugStart is not null && _beforeExecuteHandler is not null) + { + _debugStart.BeforeExecute -= _beforeExecuteHandler; + } + if (_runNoDebug is not null && _beforeExecuteHandler is not null) + { + _runNoDebug.BeforeExecute -= _beforeExecuteHandler; + } + } + catch { } + } +} diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs index d49f7d7ff0cc..d03c8a8bcb36 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace Uno.UI.RemoteControl.VS.AppLaunch; diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index 1a0614301a35..19249a36c8e1 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -14,6 +14,7 @@ using System.Threading.Tasks; using EnvDTE; using EnvDTE80; +using Microsoft; using Microsoft.Extensions.DependencyInjection; using Microsoft.Internal.VisualStudio.Shell; using Microsoft.VisualStudio.Imaging; @@ -27,6 +28,7 @@ using Uno.UI.RemoteControl.VS.Helpers; using Uno.UI.RemoteControl.VS.IdeChannel; using Uno.UI.RemoteControl.VS.Notifications; +using Uno.UI.RemoteControl.VS.AppLaunch; using ILogger = Uno.UI.RemoteControl.VS.Helpers.ILogger; using Task = System.Threading.Tasks.Task; using _udeiMsg = Uno.UI.RemoteControl.Messaging.IdeChannel.DevelopmentEnvironmentStatusIdeMessage; @@ -68,7 +70,8 @@ public partial class EntryPoint : IDisposable private _dispBuildEvents_OnBuildProjConfigBeginEventHandler? _onBuildProjConfigBeginHandler; private UnoMenuCommand? _unoMenuCommand; private IUnoDevelopmentEnvironmentIndicator? _udei; - private CompositeCommandHandler _commands; + private VsAppLaunchIdeBridge? _appLaunchIdeBridge; + private readonly CompositeCommandHandler _commands; // Legacy API v2 public EntryPoint( @@ -172,6 +175,8 @@ private async Task InitializeAsync(AsyncPackage asyncPackage, IServiceProvider s _onBuildProjConfigBeginHandler = (string project, string projectConfig, string platform, string solutionConfig) => _ = UpdateProjectsAsync(); _dte.Events.BuildEvents.OnBuildProjConfigBegin += _onBuildProjConfigBeginHandler; + await InitializeAppLaunchTrackingAsync(asyncPackage); + // Start the RC server early, as iOS and Android projects capture the globals early // and don't recreate it unless out-of-process msbuild.exe instances are terminated. // @@ -199,6 +204,19 @@ private async Task InitializeAsync(AsyncPackage asyncPackage, IServiceProvider s TelemetryHelper.DataModelTelemetrySession.AddSessionChannel(new TelemetryEventListener(this)); } + private async Task InitializeAppLaunchTrackingAsync(AsyncPackage asyncPackage) + { + // Initialize App Launch tracking (Play/Build events → state machine) + var stateService = new VsAppLaunchStateService(); + stateService.StateChanged += (s, e) => + { + var key = e.StateDetails is var d ? d.StartupProjectPath : null; + _debugAction?.Invoke($"[AppLaunch] {e.Previous} -> {e.Current} key={key}"); + }; + + _appLaunchIdeBridge = await VsAppLaunchIdeBridge.CreateAsync(asyncPackage, _dte2, stateService); + } + private Task> OnProvideGlobalPropertiesAsync() { Dictionary properties = new() @@ -837,6 +855,7 @@ public void Dispose() _debuggerObserver?.Dispose(); _infoBarFactory?.Dispose(); _unoMenuCommand?.Dispose(); + _appLaunchIdeBridge?.Dispose(); } catch (Exception e) { From 17fb9ae19543fb907a172288148872afec80db9a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 10 Oct 2025 10:05:29 -0400 Subject: [PATCH 20/49] feat(app-launch): Preserve original instance during state transitions and add debug metadata handling - Refactored test setup to include reusable `AssemblyInfoReader`. --- .../Given_VsAppLaunchStateService.cs | 33 ++++ .../RealAppLaunchIntegrationTests.cs | 2 +- .../Helpers/AssemblyInfoReader.cs | 52 ------- ...no.UI.RemoteControl.DevServer.Tests.csproj | 2 + .../IRemoteControlServer.cs | 2 - .../AppLaunch/AppLaunchDetails.cs | 19 ++- .../AppLaunch/VsAppLaunchIdeBridge.cs | 7 +- .../AppLaunch/VsAppLaunchStateConsumer.cs | 147 ++++++++++++++++++ .../AppLaunch/VsAppLaunchStateService.cs | 15 +- src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 3 + .../Helpers/AssemblyInfoReader.cs | 54 +++++++ 11 files changed, 272 insertions(+), 64 deletions(-) delete mode 100644 src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs create mode 100644 src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs create mode 100644 src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs index e2be002f789b..17dd9471a83d 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs @@ -174,6 +174,39 @@ public void WhenStartWithKeyThenChangeKey_BeforeBuild_ThenOldTimerCanceledAndNew events.Should().HaveCount(4); } + [TestMethod] + public void StartKeepsOriginalInstance_WhenNotifyBuildReceivedWithEqualDifferentInstance() + { + // Arrange - use a details type that compares equal only on Key, but carries extra info + using var sut = CreateService
(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var original = new Details { Key = 42, Extra = "original" }; + var equivalent = new Details { Key = 42, Extra = "other" }; + + // Act + sut.Start(original); + + // Assert initial event contains the original instance (reference) + events.Should().HaveCount(1); + events[0].StateDetails.Should().BeSameAs(original); + + // Act: notify build using an equivalent but different instance + sut.NotifyBuild(equivalent, BuildNotification.Began); + + // Assert: the state moved to BuildInProgress and the StateDetails kept the original instance + events.Should().HaveCount(2); + events[1].Current.Should().Be(VsAppLaunchStateService
.LaunchState.BuildInProgress); + events[1].StateDetails.Should().BeSameAs(original, "the service must preserve the original instance and not replace it with an equal one"); + } + + private sealed class Details + { + public int Key { get; set; } + public string? Extra { get; set; } + + public override bool Equals(object? obj) => obj is Details d && d.Key == Key; + public override int GetHashCode() => Key.GetHashCode(); + } + [TestMethod] public void WhenSuccessReportedWithoutBegan_ThenBuildSucceededThenIdleAndKeyCleared() { diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 4f158016e76e..2362dcf9a364 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -1,9 +1,9 @@ using System.Diagnostics; using System.Text; using System.Text.Json; -using System.Net.Http; using Uno.UI.RemoteControl.DevServer.Tests.Telemetry; using Uno.UI.RemoteControl.DevServer.Tests.Helpers; +using Uno.UI.RemoteControl.VS.Helpers; namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs deleted file mode 100644 index 206fcbd18423..000000000000 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/AssemblyInfoReader.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.IO; -using System.Reflection.Metadata; -using System.Reflection.PortableExecutable; - -namespace Uno.UI.RemoteControl.DevServer.Tests.Helpers; - -internal static class AssemblyInfoReader -{ - /// - /// Reads MVID and TargetPlatformAttribute.PlatformName (if present) from an assembly file without loading it. - /// - public static (Guid Mvid, string? PlatformName) Read(string assemblyPath) - { - using var fs = File.OpenRead(assemblyPath); - using var pe = new PEReader(fs, PEStreamOptions.LeaveOpen); - var md = pe.GetMetadataReader(); - - // MVID - var mvid = md.GetGuid(md.GetModuleDefinition().Mvid); - - string? platformName = null; - // [assembly: TargetPlatformAttribute("Desktop1.0")] - // Namespace: System.Runtime.Versioning - foreach (var caHandle in md.GetAssemblyDefinition().GetCustomAttributes()) - { - var ca = md.GetCustomAttribute(caHandle); - if (ca.Constructor.Kind != HandleKind.MemberReference) - { - continue; - } - - var mr = md.GetMemberReference((MemberReferenceHandle)ca.Constructor); - var ct = md.GetTypeReference((TypeReferenceHandle)mr.Parent); - var typeName = md.GetString(ct.Name); - var typeNs = md.GetString(ct.Namespace); - - if (typeName == "TargetPlatformAttribute" && typeNs == "System.Runtime.Versioning") - { - var valReader = md.GetBlobReader(ca.Value); - // Blob layout: prolog (0x0001), then fixed args - if (valReader.ReadUInt16() == 1) // prolog - { - platformName = valReader.ReadSerializedString(); - } - break; - } - } - - return (mvid, platformName); - } -} diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index 61950a3f854d..8be0f9f8fa76 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -38,6 +38,8 @@ + + diff --git a/src/Uno.UI.RemoteControl.Messaging/IRemoteControlServer.cs b/src/Uno.UI.RemoteControl.Messaging/IRemoteControlServer.cs index 2c44f55d49c5..995b0dd73bfc 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IRemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IRemoteControlServer.cs @@ -1,6 +1,4 @@ using System.Threading.Tasks; -using Uno.UI.RemoteControl; -using Uno.UI.RemoteControl.HotReload.Messages; using Uno.UI.RemoteControl.Messaging.IdeChannel; namespace Uno.UI.RemoteControl.Host diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs index b3c61723e493..f5e8420b3259 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/AppLaunchDetails.cs @@ -1,7 +1,22 @@ -namespace Uno.UI.RemoteControl.VS.AppLaunch; +using System; + +namespace Uno.UI.RemoteControl.VS.AppLaunch; /// /// Correlation details for a single application launch cycle. /// For now we key on StartupProjectPath which is stable across the Play/Build sequence. /// -internal readonly record struct AppLaunchDetails(string? StartupProjectPath); +/// Path to the startup project. +/// Whether the application was launched in debug mode (true) or without debugger (false). +internal readonly record struct AppLaunchDetails(string? StartupProjectPath, bool? IsDebug = null) +{ + /// + /// Determines equality based only on StartupProjectPath, allowing IsDebug to be updated during the lifecycle. + /// + public bool Equals(AppLaunchDetails other) => string.Equals(StartupProjectPath, other.StartupProjectPath, StringComparison.OrdinalIgnoreCase); + + /// + /// Gets hash code based only on StartupProjectPath for consistent correlation. + /// + public override int GetHashCode() => StartupProjectPath?.ToUpperInvariant().GetHashCode() ?? 0; +} diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs index beada7b2ed6b..a7fb7ad02d34 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs @@ -60,11 +60,16 @@ private async Task InitializeAsync() private void OnBeforeExecute(string guid, int id, object customIn, object customOut, ref bool cancelDefault) { + // Determine if this is a debug launch based on command ID + var isDebug = id == (int)VSConstants.VSStd97CmdID.Start; // Start = debug, StartNoDebug = no debug + // Fire-and-forget; collect details on UI thread when needed _package.JoinableTaskFactory.RunAsync(async () => { var details = await CollectStartupInfoAsync(); - _stateService.Start(details); + // Update the details with debug information + var detailsWithDebug = details with { IsDebug = isDebug }; + _stateService.Start(detailsWithDebug); }).FileAndForget("uno/appLaunch/onBeforeExecute"); } diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs new file mode 100644 index 000000000000..ce9f758b55b9 --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.ProjectSystem; +using Microsoft.VisualStudio.ProjectSystem.Properties; +using Uno.UI.RemoteControl.Messaging.IdeChannel; +using Uno.UI.RemoteControl.VS.IdeChannel; +using Uno.UI.RemoteControl.VS.Helpers; + +namespace Uno.UI.RemoteControl.VS.AppLaunch; + +internal sealed class VsAppLaunchStateConsumer : IDisposable +{ + private readonly AsyncPackage _package; + private readonly VsAppLaunchStateService _stateService; + private readonly Func _ideChannelAccessor; + + private VsAppLaunchStateConsumer( + AsyncPackage package, + VsAppLaunchStateService stateService, + Func ideChannelAccessor) + { + _package = package; + _stateService = stateService; + _ideChannelAccessor = ideChannelAccessor; + } + + public static async Task CreateAsync( + AsyncPackage package, + VsAppLaunchStateService stateService, + Func ideChannelAccessor) + { + var c = new VsAppLaunchStateConsumer(package, stateService, ideChannelAccessor); + await c.InitializeAsync(); + return c; + } + + private Task InitializeAsync() + { + // Only subscribe; handling will run on the package's JoinableTaskFactory when needed + _stateService.StateChanged += OnStateChanged; + return Task.CompletedTask; + } + + private void OnStateChanged(object? sender, StateChangedEventArgs e) + { + if (e.BuildSucceeded) + { + _package.JoinableTaskFactory.RunAsync(async () => + { + await ExtractAndSendAppLaunchInfoAsync(e.StateDetails); + }).FileAndForget("uno/appLaunch/stateConsumer/onStateChanged"); + } + } + + private async Task ExtractAndSendAppLaunchInfoAsync(AppLaunchDetails details) + { + try + { + var targetPath = await GetTargetPathAsync(details.StartupProjectPath); + if (targetPath == null || !File.Exists(targetPath)) + { + return; + } + + var (mvid, platform) = AssemblyInfoReader.Read(targetPath); + if (mvid == Guid.Empty || string.IsNullOrEmpty(platform)) + { + return; + } + + var ideChannel = _ideChannelAccessor(); + if (ideChannel != null && details.IsDebug is { } isDebug) + { + var message = new AppLaunchRegisterIdeMessage(mvid, platform, isDebug); + await ideChannel.SendToDevServerAsync(message, CancellationToken.None); + } + } + catch + { + // swallow + } + } + + private async Task GetTargetPathAsync(string? projectPath) + { + if (string.IsNullOrEmpty(projectPath)) + { + return null; + } + + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + try + { + var sbm = await _package.GetServiceAsync( + typeof(SVsSolutionBuildManager)); + if (sbm is IVsSolutionBuildManager2 sbm2 + && sbm2.get_StartupProject(out var hierarchy) == Microsoft.VisualStudio.VSConstants.S_OK + && hierarchy != null) + { + var unconfiguredProject = await GetUnconfiguredProjectAsync(hierarchy); + if (unconfiguredProject != null + && unconfiguredProject.FullPath.Equals(projectPath, StringComparison.InvariantCultureIgnoreCase)) + { + var configuredProject = await unconfiguredProject.GetSuggestedConfiguredProjectAsync(); + if (configuredProject?.Services?.ProjectPropertiesProvider != null) + { + var projectProperties = configuredProject.Services.ProjectPropertiesProvider.GetCommonProperties(); + var targetPath = await projectProperties.GetEvaluatedPropertyValueAsync("TargetPath"); + if (!string.IsNullOrEmpty(targetPath)) + { + return targetPath; + } + } + } + } + } + catch + { + // ignore + } + + return null; + } + + private static async Task GetUnconfiguredProjectAsync( + IVsHierarchy hierarchy) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + return hierarchy is IVsBrowseObjectContext context + ? context.UnconfiguredProject + : null; + } + + public void Dispose() + { + try + { + _stateService.StateChanged -= OnStateChanged; + } + catch { } + } +} diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs index d03c8a8bcb36..f5ccfc130272 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs @@ -116,7 +116,8 @@ public void NotifyBuild(TStateDetails details, BuildNotification notification) case BuildNotification.Began: if (State == LaunchState.PlayInvokedPendingBuild) { - SetState(LaunchState.BuildInProgress, details); + // Preserve the original correlated details instance when transitioning to BuildInProgress + SetState(LaunchState.BuildInProgress, _snapshot.StateDetails); ResetTimer(startNew: false); } break; @@ -126,12 +127,14 @@ public void NotifyBuild(TStateDetails details, BuildNotification notification) break; case BuildNotification.CompletedSuccess: - SetState(LaunchState.BuildSucceeded, details); + // Ensure the BuildSucceeded event carries the same correlated details + SetState(LaunchState.BuildSucceeded, _snapshot.StateDetails); Reset(); break; case BuildNotification.CompletedFailure: - SetState(LaunchState.BuildFailed, details); + // Ensure the BuildFailed event carries the same correlated details + SetState(LaunchState.BuildFailed, _snapshot.StateDetails); Reset(); break; } @@ -151,7 +154,7 @@ public void Reset() public void Dispose() => Reset(); - private void SetState(LaunchState state, TStateDetails? details) + private void SetState(LaunchState state, TStateDetails? details = default) { var prev = _snapshot.State; var prevDetails = _snapshot.StateDetails; @@ -169,7 +172,7 @@ private void SetState(LaunchState state, TStateDetails? details) } else { - _snapshot = new Snapshot(details, state); + _snapshot = new Snapshot(details ?? prevDetails, state); } StateChanged?.Invoke(this, new StateChangedEventArgs(_timeProvider.GetUtcNow(), prev, state, _snapshot.StateDetails)); @@ -237,5 +240,5 @@ internal sealed class StateChangedEventArgs( public VsAppLaunchStateService.LaunchState Previous { get; } = previous; public VsAppLaunchStateService.LaunchState Current { get; } = current; public TStateDetails? StateDetails { get; } = details; - public bool Succeeded => Current == VsAppLaunchStateService.LaunchState.BuildSucceeded; + public bool BuildSucceeded => Current == VsAppLaunchStateService.LaunchState.BuildSucceeded; } diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index 19249a36c8e1..b80ab7767109 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -71,6 +71,7 @@ public partial class EntryPoint : IDisposable private UnoMenuCommand? _unoMenuCommand; private IUnoDevelopmentEnvironmentIndicator? _udei; private VsAppLaunchIdeBridge? _appLaunchIdeBridge; + private VsAppLaunchStateConsumer? _appLaunchStateConsumer; private readonly CompositeCommandHandler _commands; // Legacy API v2 @@ -215,6 +216,7 @@ private async Task InitializeAppLaunchTrackingAsync(AsyncPackage asyncPackage) }; _appLaunchIdeBridge = await VsAppLaunchIdeBridge.CreateAsync(asyncPackage, _dte2, stateService); + _appLaunchStateConsumer = await VsAppLaunchStateConsumer.CreateAsync(asyncPackage, stateService, () => _ideChannelClient); } private Task> OnProvideGlobalPropertiesAsync() @@ -856,6 +858,7 @@ public void Dispose() _infoBarFactory?.Dispose(); _unoMenuCommand?.Dispose(); _appLaunchIdeBridge?.Dispose(); + _appLaunchStateConsumer?.Dispose(); } catch (Exception e) { diff --git a/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs b/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs new file mode 100644 index 000000000000..1d36e906438a --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +// Temporarily suppress formatting rule until code is aligned with project style +#pragma warning disable IDE0055 + +namespace Uno.UI.RemoteControl.VS.Helpers; + +internal static class AssemblyInfoReader +{ + /// + /// Reads MVID and TargetPlatformAttribute.PlatformName (if present) from an assembly file without loading it. + /// + public static (Guid Mvid, string? PlatformName) Read(string assemblyPath) + { + using var fs = File.OpenRead(assemblyPath); + using var pe = new PEReader(fs, PEStreamOptions.LeaveOpen); + var md = pe.GetMetadataReader(); + + // MVID + var mvid = md.GetGuid(md.GetModuleDefinition().Mvid); + + string? platformName = null; + // [assembly: TargetPlatformAttribute("Desktop1.0")] + // Namespace: System.Runtime.Versioning + foreach (var caHandle in md.GetAssemblyDefinition().GetCustomAttributes()) + { + var ca = md.GetCustomAttribute(caHandle); + if (ca.Constructor.Kind != HandleKind.MemberReference) + { + continue; + } + + var mr = md.GetMemberReference((MemberReferenceHandle)ca.Constructor); + var ct = md.GetTypeReference((TypeReferenceHandle)mr.Parent); + var typeName = md.GetString(ct.Name); + var typeNs = md.GetString(ct.Namespace); + + if (typeName == "TargetPlatformAttribute" && typeNs == "System.Runtime.Versioning") + { + var valReader = md.GetBlobReader(ca.Value); + // Blob layout: prolog (0x0001), then fixed args + if (valReader.ReadUInt16() == 1) // prolog + { + platformName = valReader.ReadSerializedString(); + } + break; + } + } + + return (mvid, platformName); + } +} From 38d094f3b8add2d92dbe8c0b2c7054470db0d8fc Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 10 Oct 2025 13:11:44 -0400 Subject: [PATCH 21/49] feat(app-launch): Add retention handling for unmatched launches and testing coverage - Implemented retention window for unmatched app launches in `VsAppLaunchStateService`. --- .../Given_VsAppLaunchStateService.cs | 76 +++++++++ .../AppLaunch/VsAppLaunchStateService.cs | 150 ++++++++++++++++-- 2 files changed, 210 insertions(+), 16 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs index 17dd9471a83d..f040a7d77cb2 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_VsAppLaunchStateService.cs @@ -339,4 +339,80 @@ public void Stress_ManyCycles_WithMixedOutcomes_NoLeakAndAlwaysIdleAtEnd() // Basic sanity: we produced a lot of events but the final state is Idle with no active key sut.State.Should().Be(VsAppLaunchStateService.LaunchState.Idle); } + + [TestMethod] + public void WhenLaunchedArrivesBeforeRegister_ItIsRetainedAndMatchedOnStart() + { + // Arrange + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + var key = Guid.NewGuid(); + + // Act: Launched arrives before register + var launchedAt = clock.GetUtcNow(); + sut.NotifyLaunched(key, launchedAt); + + // No events should be emitted yet + events.Should().BeEmpty(); + + // Now Register/Start occurs shortly after + clock.Advance(TimeSpan.FromSeconds(1)); + sut.Start(key); + + // Assert + // The Start should emit PlayInvokedPendingBuild and also a StateChanged carrying the computed negative + // or small delay value via RegisterToLaunchedDelay on the same or subsequent event depending on timing. + events.Should().NotBeEmpty(); + // First event must be PlayInvokedPendingBuild + events[0].Current.Should().Be(VsAppLaunchStateService.LaunchState.PlayInvokedPendingBuild); + // There should be a StateChanged with RegisterToLaunchedDelay not null (emitted during Start) + var matching = events.FirstOrDefault(e => e.RegisterToLaunchedDelay.HasValue); + matching.Should().NotBeNull("Start should produce an event that carries the RegisterToLaunchedDelay"); + // Because the launched event arrived earlier than the register time, the computed delay must be negative + matching!.RegisterToLaunchedDelay.HasValue.Should().BeTrue(); + matching!.RegisterToLaunchedDelay!.Value.Should().BeLessThan(TimeSpan.Zero); + } + + [TestMethod] + public void WhenLaunchedIsNotMatched_ItIsScavengedAfterRetentionWindow() + { + // Arrange + var retention = TimeSpan.FromMilliseconds(200); + using var sut = CreateService(out var clock, out var events, window: TimeSpan.FromSeconds(5)); + // Replace options to set LaunchedRetention via reflection is cumbersome; instead create service directly + var options = new VsAppLaunchStateService.Options { BuildWaitWindow = TimeSpan.FromSeconds(5), LaunchedRetention = retention }; + clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var evts = new List>(); + var sut2 = new VsAppLaunchStateService(clock, options); + sut2.StateChanged += (_, e) => evts.Add(e); + + var key = Guid.NewGuid(); + + // Act: Launched arrives + var launchedAt = clock.GetUtcNow(); + sut2.NotifyLaunched(key, launchedAt); + + // Advance less than retention: still retained + clock.Advance(TimeSpan.FromMilliseconds(100)); + + // Start now should match + sut2.Start(key); + // We expect matching: Start emitted and RegisterToLaunchedDelay present + evts.Should().NotBeEmpty(); + evts.Any(e => e.RegisterToLaunchedDelay.HasValue).Should().BeTrue(); + + // Now test scavenging: create another service and send launched but do not start; ensure it is removed + var key2 = Guid.NewGuid(); + var sut3 = new VsAppLaunchStateService(clock, options); + var evts3 = new List>(); + sut3.StateChanged += (_, e) => evts3.Add(e); + sut3.NotifyLaunched(key2, clock.GetUtcNow()); + + // Advance past retention window + clock.Advance(retention + TimeSpan.FromMilliseconds(50)); + + // Now Start should not find retained entry; Start should emit normal PlayInvokedPendingBuild with no RegisterToLaunchedDelay + sut3.Start(key2); + evts3.Should().NotBeEmpty(); + evts3.Any(e => e.RegisterToLaunchedDelay.HasValue).Should().BeFalse(); + } } diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs index f5ccfc130272..4f4aef75240c 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading; namespace Uno.UI.RemoteControl.VS.AppLaunch; @@ -16,10 +17,16 @@ internal sealed class VsAppLaunchStateService : IDisposable public sealed class Options { /// - /// Time window during which a build is expected after Play is pressed. - /// Default is 5 seconds. + /// Time window during which a build is expected to start after Play is pressed. + /// Default is 8 seconds. /// - public TimeSpan BuildWaitWindow { get; init; } = TimeSpan.FromSeconds(5); + public TimeSpan BuildWaitWindow { get; init; } = TimeSpan.FromSeconds(8); + + /// + /// How long to retain an "app launched" notification received before a Register/Start so it can + /// be matched later. Default is 30 seconds. + /// + public TimeSpan LaunchedRetention { get; init; } = TimeSpan.FromSeconds(30); } /// @@ -61,15 +68,20 @@ public enum LaunchState private readonly TimeProvider _timeProvider; private readonly Options _options; private ITimer? _timer; + // Retained launched notifications for details that arrived before a Register/Start. + // We keep an immutable collection of (details, launchedAt, timer) and update it atomically using + // ImmutableInterlocked.Update to ensure thread-safety without locking. Expected size is small. + private ImmutableArray<(TStateDetails? Details, DateTimeOffset LaunchedAt, ITimer? Timer)> _launchedEntries + = ImmutableArray<(TStateDetails?, DateTimeOffset, ITimer?)>.Empty; private static readonly IEqualityComparer _detailsComparer = EqualityComparer.Default; /// /// Immutable snapshot of the current cycle and state. /// Contains the correlation StateDetails and current high-level state. /// - private record struct Snapshot(TStateDetails? StateDetails, LaunchState State); + private record struct Snapshot(TStateDetails? StateDetails, LaunchState State, DateTimeOffset? StartTimestampUtc); - private Snapshot _snapshot = new Snapshot(default, LaunchState.Idle); + private Snapshot _snapshot = new Snapshot(default, LaunchState.Idle, null); /// /// Current state of the state machine. @@ -96,10 +108,108 @@ public void Start(TStateDetails details) // Cancel any previous wait timer and initialize the new cycle for the provided details. try { _timer?.Dispose(); } catch { } _timer = null; - SetState(LaunchState.PlayInvokedPendingBuild, details); + + // If we have a retained launched entry for these details, compute the register->launched delay + // (launchedAt - registerTime). Note: if the launchedAt is earlier than registerTime this will be negative, + // which is expected per requirements. + TimeSpan? registerToLaunchedDelay = null; + var registerTime = _timeProvider.GetUtcNow(); + // Atomically remove a retained entry for these details (if any) and capture the removed value. + (bool removed, DateTimeOffset LaunchedAt, ITimer? Timer) removedEntry = (false, default, null); + ImmutableInterlocked.Update(ref _launchedEntries, current => + { + var idx = -1; + for (var i = 0; i < current.Length; i++) + { + if (_detailsComparer.Equals(current[i].Details, details)) + { + idx = i; + break; + } + } + if (idx < 0) return current; + removedEntry = (true, current[idx].LaunchedAt, current[idx].Timer); + return current.RemoveAt(idx); + }); + if (removedEntry.removed) + { + registerToLaunchedDelay = removedEntry.LaunchedAt - registerTime; + try { removedEntry.Timer?.Dispose(); } catch { } + } + + SetState(LaunchState.PlayInvokedPendingBuild, details, startTimestampUtc: registerTime, registerToLaunchedDelay: registerToLaunchedDelay); ResetTimer(startNew: true); } + /// + /// Notify that the application has launched (e.g., the runtime reported back) at the given timestamp. + /// This may arrive before or after a Register/Start. If it arrives earlier it will be retained for + /// and matched when a Register/Start occurs. If it arrives while a + /// cycle is active, an event will be emitted that includes the computed delay. + /// + public void NotifyLaunched(TStateDetails details, DateTimeOffset launchedAtUtc) + { + // If there is an active snapshot for the same details, compute delay vs the snapshot's start timestamp + // and emit an event immediately so listeners can correlate the two. + if (_detailsComparer.Equals(details, _snapshot.StateDetails) && _snapshot.StartTimestampUtc is { } startTs) + { + var delay = launchedAtUtc - startTs; + // Raise an informational StateChanged event preserving the current state but including the delay. + StateChanged?.Invoke(this, new StateChangedEventArgs(_timeProvider.GetUtcNow(), _snapshot.State, _snapshot.State, _snapshot.StateDetails, delay)); + return; + } + + // Otherwise retain it until a Register/Start or until scavenging. + // Replace any existing retained entry for these details. + // Atomically remove any existing entry for these details and dispose its timer. + ImmutableInterlocked.Update(ref _launchedEntries, current => + { + var idx = -1; + for (var i = 0; i < current.Length; i++) + { + if (_detailsComparer.Equals(current[i].Details, details)) + { + idx = i; + break; + } + } + if (idx < 0) return current; + try { current[idx].Timer?.Dispose(); } catch { } + return current.RemoveAt(idx); + }); + + // Create a scavenging timer to remove the entry after the retention window. + ITimer? thisTimer = null; + thisTimer = _timeProvider.CreateTimer(_ => + { + try + { + // Remove the entry matching details (if still present) and dispose its timer. + ImmutableInterlocked.Update(ref _launchedEntries, current => + { + var idx = -1; + for (var i = 0; i < current.Length; i++) + { + if (_detailsComparer.Equals(current[i].Details, details)) + { + idx = i; + break; + } + } + if (idx < 0) return current; + try { current[idx].Timer?.Dispose(); } catch { } + return current.RemoveAt(idx); + }); + } + finally + { + try { thisTimer?.Dispose(); } catch { } + } + }, null, dueTime: _options.LaunchedRetention, period: Timeout.InfiniteTimeSpan); + + ImmutableInterlocked.Update(ref _launchedEntries, current => current.Add((details, launchedAtUtc, thisTimer))); + } + /// /// Notify a build-related event for the given StateDetails. /// Events for unrelated details are ignored. @@ -117,7 +227,7 @@ public void NotifyBuild(TStateDetails details, BuildNotification notification) if (State == LaunchState.PlayInvokedPendingBuild) { // Preserve the original correlated details instance when transitioning to BuildInProgress - SetState(LaunchState.BuildInProgress, _snapshot.StateDetails); + SetState(LaunchState.BuildInProgress, _snapshot.StateDetails, startTimestampUtc: _snapshot.StartTimestampUtc); ResetTimer(startNew: false); } break; @@ -128,13 +238,13 @@ public void NotifyBuild(TStateDetails details, BuildNotification notification) case BuildNotification.CompletedSuccess: // Ensure the BuildSucceeded event carries the same correlated details - SetState(LaunchState.BuildSucceeded, _snapshot.StateDetails); + SetState(LaunchState.BuildSucceeded, _snapshot.StateDetails, startTimestampUtc: _snapshot.StartTimestampUtc); Reset(); break; case BuildNotification.CompletedFailure: // Ensure the BuildFailed event carries the same correlated details - SetState(LaunchState.BuildFailed, _snapshot.StateDetails); + SetState(LaunchState.BuildFailed, _snapshot.StateDetails, startTimestampUtc: _snapshot.StartTimestampUtc); Reset(); break; } @@ -149,16 +259,17 @@ public void Reset() try { _timer?.Dispose(); } catch { } _timer = null; // Clear active StateDetails and emit Idle with no correlation details. - SetState(LaunchState.Idle, default); + SetState(LaunchState.Idle, default, startTimestampUtc: null); } public void Dispose() => Reset(); - private void SetState(LaunchState state, TStateDetails? details = default) + private void SetState(LaunchState state, TStateDetails? details = default, DateTimeOffset? startTimestampUtc = null, TimeSpan? registerToLaunchedDelay = null) { var prev = _snapshot.State; var prevDetails = _snapshot.StateDetails; - if (prev == state && _detailsComparer.Equals(details, prevDetails)) + var prevStartTs = _snapshot.StartTimestampUtc; + if (prev == state && _detailsComparer.Equals(details, prevDetails) && Nullable.Equals(startTimestampUtc, prevStartTs)) { return; } @@ -168,14 +279,14 @@ private void SetState(LaunchState state, TStateDetails? details = default) // When transitioning back to Idle, set the snapshot to the provided details so listeners // receive the correlated StateDetails for the cycle that just ended. Callers (Reset) // may clear the snapshot afterwards. - _snapshot = new Snapshot(details, LaunchState.Idle); + _snapshot = new Snapshot(details, LaunchState.Idle, startTimestampUtc); } else { - _snapshot = new Snapshot(details ?? prevDetails, state); + _snapshot = new Snapshot(details ?? prevDetails, state, startTimestampUtc ?? prevStartTs); } - StateChanged?.Invoke(this, new StateChangedEventArgs(_timeProvider.GetUtcNow(), prev, state, _snapshot.StateDetails)); + StateChanged?.Invoke(this, new StateChangedEventArgs(_timeProvider.GetUtcNow(), prev, state, _snapshot.StateDetails, registerToLaunchedDelay)); } private void ResetTimer(bool startNew) @@ -233,12 +344,19 @@ internal sealed class StateChangedEventArgs( DateTimeOffset timestampUtc, VsAppLaunchStateService.LaunchState previous, VsAppLaunchStateService.LaunchState current, - TStateDetails? details) + TStateDetails? details, + TimeSpan? registerToLaunchedDelay = null) : EventArgs { public DateTimeOffset TimestampUtc { get; } = timestampUtc; public VsAppLaunchStateService.LaunchState Previous { get; } = previous; public VsAppLaunchStateService.LaunchState Current { get; } = current; public TStateDetails? StateDetails { get; } = details; + + /// + /// If not null, indicates a delay computed between register/start and a previously received launched notification. + /// **May be negative** if the launched event arrived earlier than register time. + /// + public TimeSpan? RegisterToLaunchedDelay { get; } = registerToLaunchedDelay; public bool BuildSucceeded => Current == VsAppLaunchStateService.LaunchState.BuildSucceeded; } From cc11a72c2efcc2ca319637e176403cbe5861ca1a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 10 Oct 2025 13:43:24 -0400 Subject: [PATCH 22/49] fix(app-launch): Dispose of `_scavengeTimer` to prevent resource leaks in `ApplicationLaunchMonitor` --- .../AppLaunch/ApplicationLaunchMonitor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 319f52b3c1de..63efec1a30ed 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -2,7 +2,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace Uno.UI.RemoteControl.Server.AppLaunch; @@ -290,6 +289,8 @@ private void RunScavengePass() /// public void Dispose() { + _scavengeTimer?.Dispose(); + // Dispose all timers and clear pending timeout tasks foreach (var kvp in _timeoutTasks.ToArray()) { From b9b3008180a74a94576599f3df3a4c6195b4f52a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 10 Oct 2025 16:08:59 -0400 Subject: [PATCH 23/49] feat(app-launch): Add new endpoint to register app launches via assembly path on `/app-launch/{assemblyPath}` for app launching registration. --- .../AppLaunch/AppLaunchIntegrationTests.cs | 65 ++++++++++++++++ .../RemoteControlExtensions.cs | 75 ++++++++++++++++--- .../Uno.UI.RemoteControl.Host.csproj | 1 + 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index 168fdd5bca52..eac6438a63c4 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -81,6 +81,71 @@ [new ServerEndpointAttribute("localhost", helper.Port)], } } + [TestMethod] + public async Task WhenRegisteredByAssemblyPathAndRuntimeConnects_SuccessEventEmitted() + { + // PRE-ARRANGE: Create a solution file + var solution = SolutionHelper!; + await solution.CreateSolutionFileAsync(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success_by_path")); + await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); + + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + var asm = typeof(AppLaunchIntegrationTests).Assembly; + var asmPath = asm.Location; + var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var isDebug = Debugger.IsAttached; + + // ACT - STEP 1: Register app launch via HTTP GET using assembly path (new endpoint) + using (var http = new HttpClient()) + { + // The assembly path should be URL-encoded + var encodedPath = Uri.EscapeDataString(asmPath); + var url = $"http://localhost:{helper.Port}/app-launch/{encodedPath}?IsDebug={isDebug.ToString().ToLowerInvariant()}"; + var response = await http.GetAsync(url, CT); + response.EnsureSuccessStatusCode(); + TestContext!.WriteLine("Http Response: " + await response.Content.ReadAsStringAsync()); + } + + // ACT - STEP 2: Connect from application (simulating app -> dev server) + var rcClient = RemoteControlClient.Initialize( + typeof(AppLaunchIntegrationTests), + [new ServerEndpointAttribute("localhost", helper.Port)], + _serverProcessorAssembly, + autoRegisterAppIdentity: true); + + await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10)); + + // ACT - STEP 3: Stop and gather telemetry events + await Task.Delay(1500, CT); + await helper.AttemptGracefulShutdownAsync(CT); + + // ASSERT + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue(); + events.Should().NotBeEmpty(); + WriteEventsList(events); + AssertHasEvent(events, "uno/dev-server/app-launch/launched"); + AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); + } + finally + { + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + [TestMethod] [Ignore] public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeChannel() diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 98257778113f..535a70443aa5 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; using System.Threading; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -9,12 +7,12 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Uno.Extensions; -using Uno.UI.RemoteControl.Server.Helpers; using Uno.UI.RemoteControl.Services; using Uno.UI.RemoteControl.Server.Telemetry; using Uno.UI.RemoteControl.Server.AppLaunch; using Microsoft.AspNetCore.Http; -using System.Text.Json; +using System.IO; +using Uno.UI.RemoteControl.VS.Helpers; namespace Uno.UI.RemoteControl.Host { @@ -69,13 +67,12 @@ public static IApplicationBuilder UseRemoteControlServer( // Use context.RequestServices directly - it already contains both global and scoped services // The global service provider was injected as Singleton in Program.cs, so it's accessible here - using (var server = new RemoteControlServer( + using var server = new RemoteControlServer( configuration, context.RequestServices.GetService() ?? throw new InvalidOperationException("IIdeChannel is required"), - context.RequestServices)) - { - await server.RunAsync(await context.WebSockets.AcceptWebSocketAsync(), CancellationToken.None); - } + context.RequestServices); + + await server.RunAsync(await context.WebSockets.AcceptWebSocketAsync(), CancellationToken.None); } else { @@ -133,6 +130,7 @@ public static IApplicationBuilder UseRemoteControlServer( { platform = p.ToString(); } + if (context.Request.Query.TryGetValue("isDebug", out var d)) { if (bool.TryParse(d.ToString(), out var qParsed)) @@ -154,8 +152,67 @@ public static IApplicationBuilder UseRemoteControlServer( context.Response.StatusCode = StatusCodes.Status200OK; await context.Response.WriteAsync("registered"); }); + + // New HTTP GET endpoint to register an app launch by providing the absolute assembly file path. + // Example: /app-launch/C:/path/to/MyApp.dll?IsDebug=true + router.MapGet("app-launch/{*assemblyPath}", async context => + { + var assemblyPathValue = context.GetRouteValue("assemblyPath")?.ToString(); + if (string.IsNullOrWhiteSpace(assemblyPathValue)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Missing assembly path."); + return; + } + + // On Windows, the route will capture slashes; ensure it is a full path + var assemblyPath = assemblyPathValue!; + if (!Path.IsPathRooted(assemblyPath) || !File.Exists(assemblyPath)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Assembly path must be an existing absolute path."); + return; + } + + var isDebug = false; + if (context.Request.Query.TryGetValue("IsDebug", out var isDebugVal)) + { + if (!bool.TryParse(isDebugVal.ToString(), out isDebug)) + { + isDebug = false; + } + } + + try + { + // Read MVID and TargetPlatform without loading the assembly + var (mvid, platform) = AssemblyInfoReader.Read(assemblyPath); + + var monitor = context.RequestServices.GetRequiredService(); + monitor.RegisterLaunch(mvid, platform ?? "Desktop", isDebug); + + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("registered - application with MVID=" + mvid + " and platform=" + platform + " is now registered for launch."); + } + catch (BadImageFormatException) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("The specified file is not a valid .NET assembly."); + } + catch (Exception ex) + { + if (app.Log().IsEnabled(LogLevel.Error)) + { + app.Log().LogError(ex, "Failed to read assembly info for path: {path}", assemblyPath); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync("Failed to process assembly path."); + } + }); }); + return app; } } diff --git a/src/Uno.UI.RemoteControl.Host/Uno.UI.RemoteControl.Host.csproj b/src/Uno.UI.RemoteControl.Host/Uno.UI.RemoteControl.Host.csproj index 3d9ce61c5dfa..21feaca8bada 100644 --- a/src/Uno.UI.RemoteControl.Host/Uno.UI.RemoteControl.Host.csproj +++ b/src/Uno.UI.RemoteControl.Host/Uno.UI.RemoteControl.Host.csproj @@ -37,6 +37,7 @@ + From a9ef47d6ebde7a97110d3d12453cb2d6d71f827c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 10 Oct 2025 16:09:10 -0400 Subject: [PATCH 24/49] fix(app-launch): Ensure interception only occurs for debug builds in `VsAppLaunchIdeBridge` --- .../AppLaunch/VsAppLaunchIdeBridge.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs index a7fb7ad02d34..b4e5dae60109 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchIdeBridge.cs @@ -63,6 +63,25 @@ private void OnBeforeExecute(string guid, int id, object customIn, object custom // Determine if this is a debug launch based on command ID var isDebug = id == (int)VSConstants.VSStd97CmdID.Start; // Start = debug, StartNoDebug = no debug + // Only intercept launches for Debug builds. Match the logic used in RemoteControlGenerator + // which checks the MSBuild Configuration property equals "Debug". + // Use DTE to get the active solution configuration name when possible. + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + var activeConfigName = _dte?.Solution?.SolutionBuild?.ActiveConfiguration?.Name; + if (!string.Equals(activeConfigName, "Debug", StringComparison.OrdinalIgnoreCase)) + { + // Not a Debug configuration, do not intercept launch + return; + } + } + catch + { + // If we fail to determine configuration, conservatively bypass interception. + return; + } + // Fire-and-forget; collect details on UI thread when needed _package.JoinableTaskFactory.RunAsync(async () => { From a239cd2862daa7769e99fae9c5c6e14a3e369a01 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 10 Oct 2025 18:04:57 -0400 Subject: [PATCH 25/49] fix(app-launch): Update endpoint to use encoded paths and align with new route format --- .../AppLaunch/AppLaunchIntegrationTests.cs | 2 +- .../RemoteControlExtensions.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index eac6438a63c4..6fad1b5ab5e2 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -107,7 +107,7 @@ public async Task WhenRegisteredByAssemblyPathAndRuntimeConnects_SuccessEventEmi { // The assembly path should be URL-encoded var encodedPath = Uri.EscapeDataString(asmPath); - var url = $"http://localhost:{helper.Port}/app-launch/{encodedPath}?IsDebug={isDebug.ToString().ToLowerInvariant()}"; + var url = $"http://localhost:{helper.Port}/applaunch/asm/{encodedPath}?IsDebug={isDebug.ToString().ToLowerInvariant()}"; var response = await http.GetAsync(url, CT); response.EnsureSuccessStatusCode(); TestContext!.WriteLine("Http Response: " + await response.Content.ReadAsStringAsync()); diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 535a70443aa5..3a881f7c9a6a 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -153,11 +153,11 @@ public static IApplicationBuilder UseRemoteControlServer( await context.Response.WriteAsync("registered"); }); - // New HTTP GET endpoint to register an app launch by providing the absolute assembly file path. - // Example: /app-launch/C:/path/to/MyApp.dll?IsDebug=true - router.MapGet("app-launch/{*assemblyPath}", async context => + // Alternate HTTP GET endpoint to register an app launch by providing the absolute assembly file path with encoded path string + // Example: /app-launch/asm/C%3A%5Cpath%5Cto%5Capp.dll?IsDebug=true + router.MapGet("applaunch/asm/{*assemblyPath}", async context => { - var assemblyPathValue = context.GetRouteValue("assemblyPath")?.ToString(); + var assemblyPathValue = Uri.UnescapeDataString(context.GetRouteValue("assemblyPath")?.ToString() ?? string.Empty); if (string.IsNullOrWhiteSpace(assemblyPathValue)) { context.Response.StatusCode = StatusCodes.Status400BadRequest; @@ -175,7 +175,7 @@ public static IApplicationBuilder UseRemoteControlServer( } var isDebug = false; - if (context.Request.Query.TryGetValue("IsDebug", out var isDebugVal)) + if (context.Request.Query.TryGetValue("isDebug", out var isDebugVal)) { if (!bool.TryParse(isDebugVal.ToString(), out isDebug)) { From 5219c6a15e829d8fb7f019f07fac6bd893a9b235 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 14 Oct 2025 16:09:28 -0400 Subject: [PATCH 26/49] feat(app-launch): Refactor app launch endpoints and improve platform handling - Refactored app launch HTTP endpoints with minimal API approach and enhanced route handling (removed obsolete WebHostBuilder) - Integrated WebApplication configuration and standardized startup flow for better alignment with modern .NET practices. # Conflicts: # src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs # src/Uno.UI.RemoteControl.Host/Program.cs --- .../AppLaunch/AppLaunchIntegrationTests.cs | 9 +- .../Given_ApplicationLaunchMonitor.cs | 2 - .../Extensibility/AddIns.cs | 7 +- .../Extensibility/AddInsExtensions.cs | 17 + .../IDEChannel/IdeChannelServer.cs | 33 +- src/Uno.UI.RemoteControl.Host/Program.cs | 64 ++-- .../RemoteControlExtensions.cs | 311 +++++++++--------- src/Uno.UI.RemoteControl.Host/Startup.cs | 25 +- .../AppLaunch/ApplicationLaunchMonitor.cs | 15 +- .../IDEChannel/IDEChannelClient.cs | 39 ++- .../Helpers/ApplicationInfoHelper.cs | 4 +- .../Messages/AppLaunchMessage.cs | 2 +- .../RemoteControlClient.cs | 2 +- 13 files changed, 298 insertions(+), 232 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index 6fad1b5ab5e2..c145768a7924 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -37,14 +37,15 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() var asm = typeof(AppLaunchIntegrationTests).Assembly; var mvid = ApplicationInfoHelper.GetMvid(asm); - var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var platform = ApplicationInfoHelper.GetTargetPlatform(asm) is { } p ? "&platform=" + Uri.EscapeDataString(p) : null; var isDebug = Debugger.IsAttached; // ACT - STEP 1: Register app launch via HTTP GET (simulating IDE -> dev server) using (var http = new HttpClient()) { - var url = $"http://localhost:{helper.Port}/applaunch/{mvid}?platform={Uri.EscapeDataString(platform)}&isDebug={isDebug.ToString().ToLowerInvariant()}"; + var url = $"http://localhost:{helper.Port}/applaunch/{mvid}?isDebug={isDebug.ToString().ToLowerInvariant()}{platform}"; var response = await http.GetAsync(url, CT); + var body = await response.Content.ReadAsStringAsync(); response.EnsureSuccessStatusCode(); } @@ -99,7 +100,7 @@ public async Task WhenRegisteredByAssemblyPathAndRuntimeConnects_SuccessEventEmi var asm = typeof(AppLaunchIntegrationTests).Assembly; var asmPath = asm.Location; - var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var platform = ApplicationInfoHelper.GetTargetPlatform(asm); var isDebug = Debugger.IsAttached; // ACT - STEP 1: Register app launch via HTTP GET using assembly path (new endpoint) @@ -165,7 +166,7 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeC var asm = typeof(AppLaunchIntegrationTests).Assembly; var mvid = ApplicationInfoHelper.GetMvid(asm); - var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var platform = ApplicationInfoHelper.GetTargetPlatform(asm); var isDebug = Debugger.IsAttached; // ACT - STEP 1: Register app launch via IDE channel (IDE -> dev server) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index db8d336b43bc..f6df4bb842b6 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -230,9 +230,7 @@ public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentExcept using var sut = CreateMonitor(out _, out _, out _, out _, out _); var mvid = Guid.NewGuid(); - sut.Invoking(m => m.RegisterLaunch(mvid, null!, true)).Should().Throw(); sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true)).Should().Throw(); - sut.Invoking(m => m.ReportConnection(mvid, null!, true)).Should().Throw(); sut.Invoking(m => m.ReportConnection(mvid, string.Empty, true)).Should().Throw(); } diff --git a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs index a34f0b953312..cb42d302dc92 100644 --- a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs +++ b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs @@ -35,8 +35,6 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te var result = ProcessHelper.RunProcess("dotnet", command, wd); var targetFrameworks = Read(tmp); - _log.Log(LogLevel.Warning, $"Temp output file content: {File.Exists(tmp) switch { true => string.Join(Environment.NewLine, File.ReadAllLines(tmp)), false => "" }}"); - if (targetFrameworks.IsEmpty) { if (_log.IsEnabled(LogLevel.Warning)) @@ -45,9 +43,6 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te + "This usually indicates that the solution is in an invalid state (e.g. a referenced project is missing on disk). " + $"Please fix and restart your IDE (command used: `dotnet {command}`)."; - _log.Log(LogLevel.Warning, $"Command output: {result.output}"); - _log.Log(LogLevel.Warning, $"Error details: {result.error}"); - if (result.error is { Length: > 0 }) { _log.Log(LogLevel.Warning, new Exception(result.error), msg + " (cf. inner exception for more details.)"); @@ -60,7 +55,7 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te _log.Log(LogLevel.Warning, msg); if (result.error is { Length: > 0 }) { - _log.Log(LogLevel.Warning, $"Error details: {result.error}"); + _log.Log(LogLevel.Debug, $"Error details: {result.error}"); } } } diff --git a/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs b/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs index 1c3ce0d835a7..1de928adfa29 100644 --- a/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Uno.Utils.DependencyInjection; using Uno.UI.RemoteControl.Helpers; @@ -27,4 +28,20 @@ public static IWebHostBuilder ConfigureAddIns(this IWebHostBuilder builder, stri services.AddSingleton(new AddInsStatus(discovery, loadResults)); }); } + + public static WebApplicationBuilder ConfigureAddIns(this WebApplicationBuilder builder, string solutionFile, ITelemetry? telemetry = null) + { + var discovery = AddIns.Discover(solutionFile, telemetry); + var loadResults = AssemblyHelper.Load(discovery.AddIns, telemetry, throwIfLoadFailed: false); + + var assemblies = loadResults + .Where(result => result.Assembly is not null) + .Select(result => result.Assembly) + .ToImmutableArray(); + + builder.Services.AddFromAttributes(assemblies); + builder.Services.AddSingleton(new AddInsStatus(discovery, loadResults)); + + return builder; + } } diff --git a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs index 8a191cc29466..8fa840dbaa09 100644 --- a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs +++ b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; @@ -44,15 +45,19 @@ async Task IIdeChannel.SendToIdeAsync(IdeMessage message, CancellationToken ct) if (_proxy is null) { - this.Log().Log(LogLevel.Information, "Received an message to send to the IDE, but there is no connection available for that."); + this.Log().LogInformation( + "Received a message {MessageType} to send to the IDE, but there is no connection available for that.", + message.Scope); } else { _proxy.SendToIde(message); + ScheduleKeepAlive(); } await Task.Yield(); } + #endregion /// @@ -108,7 +113,9 @@ private async Task InitializeServer(Guid channelId) _logger.LogDebug("IDE channel successfully initialized."); } - _ = StartKeepAliveAsync(); + ScheduleKeepAlive(); + + SendKeepAlive(); // Send a keep-alive message immediately after connection return true; } @@ -123,17 +130,29 @@ private async Task InitializeServer(Guid channelId) } } - private async Task StartKeepAliveAsync() + private const int KeepAliveDelay = 10000; // 10 seconds in milliseconds + private Timer? _keepAliveTimer; + + private void ScheduleKeepAlive() { - // Note: The dev-server is expected to send message regularly ... and AS SOON AS POSSIBLE (the Task.Delay is after the first SendToIde()!). while (_pipeServer?.IsConnected ?? false) { - _proxy?.SendToIde(new KeepAliveIdeMessage("dev-server")); - - await Task.Delay(5000); + _keepAliveTimer?.Dispose(); } + + _keepAliveTimer = new Timer(_ => + { + _keepAliveTimer!.Dispose(); + _keepAliveTimer = null; + + SendKeepAlive(); + ScheduleKeepAlive(); + }, null, KeepAliveDelay, Timeout.Infinite); } + private void SendKeepAlive() => _proxy?.SendToIde(new KeepAliveIdeMessage("dev-server")); + + /// public void Dispose() { diff --git a/src/Uno.UI.RemoteControl.Host/Program.cs b/src/Uno.UI.RemoteControl.Host/Program.cs index 0092f440e1c6..c1750f34fea7 100644 --- a/src/Uno.UI.RemoteControl.Host/Program.cs +++ b/src/Uno.UI.RemoteControl.Host/Program.cs @@ -6,8 +6,10 @@ using System.Net; using System.Net.Sockets; using System.Reflection; +using Microsoft.AspNetCore.Builder; using System.Linq; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Mono.Options; @@ -133,7 +135,7 @@ static async Task Main(string[] args) // During init, we dump the logs to the console, until the logger is set up Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(logLevel).AddConsole()); - // STEP 1: Create the global service provider BEFORE WebHostBuilder + // STEP 1: Create the global service provider BEFORE WebApplication // This contains services that live for the entire duration of the server process var globalServices = new ServiceCollection(); @@ -159,21 +161,27 @@ static async Task Main(string[] args) globalServiceProvider.GetService(); #pragma warning disable ASPDEPR004 - // WebHostBuilder is deprecated in .NET 10 RC1. - // As we still build for .NET 9, ignore this warning until $(NetPrevious)=net10. - // https://github.com/aspnet/Announcements/issues/526 + // STEP 2: Create the WebApplication builder with reference to the global service provider + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = args, + ContentRootPath = Directory.GetCurrentDirectory(), + }); - // STEP 2: Create the WebHost with reference to the global service provider - var builder = new WebHostBuilder() - .UseSetting("UseIISIntegration", false.ToString()) + // Configure Kestrel and URLs + builder.WebHost .UseKestrel() .UseUrls($"http://*:{httpPort}/") .UseContentRoot(Directory.GetCurrentDirectory()) .UseStartup() - .ConfigureLogging(logging => logging - .ClearProviders() - .AddConsole() - .SetMinimumLevel(LogLevel.Debug)) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFilter("Microsoft.", LogLevel.Information); + logging.AddFilter("Microsoft.AspNetCore.Routing", LogLevel.Warning); + }) .ConfigureAppConfiguration((hostingContext, config) => { config.AddCommandLine(args); @@ -181,18 +189,26 @@ static async Task Main(string[] args) }) .ConfigureServices(services => { - services.AddSingleton(_ => globalServiceProvider.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); + services.AddRouting(); + services.Configure(builder.Configuration); + }); - services.AddSingleton( - _ => globalServiceProvider.GetRequiredService()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); - // Add the global service provider to the DI container - services.AddKeyedSingleton("global", globalServiceProvider); + builder.Services.AddSingleton( + _ => globalServiceProvider.GetRequiredService()); - // Add connection-specific telemetry services (Scoped) - services.AddConnectionTelemetry(solution); - }); + // Add the global service provider to the DI container + builder.Services.AddKeyedSingleton("global", globalServiceProvider); + + // Add connection-specific telemetry services (Scoped) + builder.Services.AddConnectionTelemetry(solution); + + // Apply Startup.ConfigureServices for compatibility with existing Startup class + new Startup(builder.Configuration).ConfigureServices(builder.Services); if (solution is not null) { @@ -202,7 +218,7 @@ static async Task Main(string[] args) else { typeof(Program).Log().Log(LogLevel.Warning, "No solution file specified, add-ins will not be loaded which means that you won't be able to use any of the uno-studio features. Usually this indicates that your version of uno's IDE extension is too old."); - builder.ConfigureServices(services => services.AddSingleton(AddInsStatus.Empty)); + builder.Services.AddSingleton(AddInsStatus.Empty); } #pragma warning restore ASPDEPR004 @@ -211,8 +227,11 @@ static async Task Main(string[] args) var host = builder.Build(); #pragma warning restore ASPDEPR008 + // Apply Startup.Configure using Minimal APIs app instance + new Startup(host.Configuration).Configure(host); + // Once the app has started, we use the logger from the host - Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory = host.Services.GetRequiredService(); + LogExtensionPoint.AmbientLoggerFactory = host.Services.GetRequiredService(); _ = host.Services.GetRequiredService().StartAsync(ct.Token); // Background services are not supported by WebHostBuilder @@ -235,9 +254,10 @@ static async Task Main(string[] args) ambientRegistry = new AmbientRegistry(host.Services.GetRequiredService>()); ambientRegistry.Register(solution, parentPID, httpPort); + await host.StartAsync(ct.Token); try { - await host.RunAsync(ct.Token); + await host.WaitForShutdownAsync(ct.Token); } finally { diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 3a881f7c9a6a..96a59a972797 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -12,208 +12,199 @@ using Uno.UI.RemoteControl.Server.AppLaunch; using Microsoft.AspNetCore.Http; using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; using Uno.UI.RemoteControl.VS.Helpers; namespace Uno.UI.RemoteControl.Host { - static class RemoteControlExtensions + internal static class RemoteControlExtensions { - public static IApplicationBuilder UseRemoteControlServer( - this IApplicationBuilder app, + public static WebApplication UseRemoteControlServer( + this WebApplication app, RemoteControlOptions options) { - app.UseRouter(router => - { - router.MapGet("rc", async context => - { - if (!context.WebSockets.IsWebSocketRequest) + app + .MapGet( + "/rc", + async context => { - context.Response.StatusCode = 400; - return; - } + await HandleWebSocketConnectionRequest(app, context); + }).WithName("RC Protocol"); - if (app.Log().IsEnabled(LogLevel.Information)) + // HTTP GET endpoint to register an app launch + app.MapGet( + "/applaunch/{mvid:guid}", + async (HttpContext context, Guid mvid, [FromQuery] string? platform, [FromQuery] bool? isDebug) => + { + await HandleAppLaunchRegistrationRequest(context, mvid, platform, isDebug); + }) + .WithName("AppLaunchRegistration"); + + // Alternate HTTP GET endpoint to register an app launch by providing the absolute assembly file path with encoded path string + // Example: /app-launch/asm/C%3A%5Cpath%5Cto%5Capp.dll?IsDebug=true + app.MapGet( + "/applaunch/asm/{*assemblyPath}", + async (HttpContext context, string assemblyPath, [FromQuery] bool? isDebug) => { - app.Log().LogInformation($"Accepted connection from {context.Connection.RemoteIpAddress}"); - } + await HandleAppLaunchRegistrationRequest(app, assemblyPath, context, isDebug); + }) + .WithName("AppLaunchRegistration(Assembly)"); + return app; + } - try - { - if (context.RequestServices.GetService() is { } configuration) - { - // Populate the scoped ConnectionContext with connection metadata - var connectionContext = context.RequestServices.GetService(); - if (connectionContext != null) - { - connectionContext.ConnectedAt = DateTimeOffset.UtcNow; - - // Track client connection in telemetry - var telemetry = context.RequestServices.GetService(); - if (telemetry != null) - { - var properties = new Dictionary - { - ["ConnectionId"] = connectionContext.ConnectionId, - }; - - telemetry.TrackEvent("client-connection-opened", properties, null); - } - - if (app.Log().IsEnabled(LogLevel.Debug)) - { - app.Log().LogDebug($"Populated connection context: {connectionContext}"); - } - } - - // Use context.RequestServices directly - it already contains both global and scoped services - // The global service provider was injected as Singleton in Program.cs, so it's accessible here - using var server = new RemoteControlServer( - configuration, - context.RequestServices.GetService() ?? throw new InvalidOperationException("IIdeChannel is required"), - context.RequestServices); - - await server.RunAsync(await context.WebSockets.AcceptWebSocketAsync(), CancellationToken.None); - } - else - { - if (app.Log().IsEnabled(LogLevel.Error)) - { - app.Log().LogError($"Unable to find configuration service"); - } - } - } - finally + private static async Task HandleWebSocketConnectionRequest(WebApplication app, HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + if (app.Log().IsEnabled(LogLevel.Information)) + { + app.Log().LogInformation("Accepted connection from {ConnectionRemoteIpAddress}", context.Connection.RemoteIpAddress); + } + + try + { + if (context.RequestServices.GetService() is { } configuration) + { + // Populate the scoped ConnectionContext with connection metadata + var connectionContext = context.RequestServices.GetService(); + if (connectionContext != null) { - // Track client disconnection in telemetry - var connectionContext = context.RequestServices.GetService(); + connectionContext.ConnectedAt = DateTimeOffset.UtcNow; + + // Track client connection in telemetry var telemetry = context.RequestServices.GetService(); - if (telemetry != null && connectionContext != null) + if (telemetry != null) { - var connectionDuration = DateTimeOffset.UtcNow - connectionContext.ConnectedAt; - var properties = new Dictionary { - ["ConnectionId"] = connectionContext.ConnectionId - }; - - var measurements = new Dictionary - { - ["ConnectionDurationSeconds"] = connectionDuration.TotalSeconds + ["ConnectionId"] = connectionContext.ConnectionId, }; - telemetry.TrackEvent("client-connection-closed", properties, measurements); + telemetry.TrackEvent("client-connection-opened", properties, null); } - if (app.Log().IsEnabled(LogLevel.Information)) + if (app.Log().IsEnabled(LogLevel.Debug)) { - app.Log().LogInformation($"Disposing connection from {context.Connection.RemoteIpAddress}"); + app.Log().LogDebug("Populated connection context: {ConnectionContext}", connectionContext); } } - }); - // HTTP GET endpoint to register an app launch - router.MapGet("applaunch/{mvid}", async context => + // Use context.RequestServices directly - it already contains both global and scoped services + // The global service provider was injected as Singleton in Program.cs, so it's accessible here + using var server = new RemoteControlServer( + configuration, + context.RequestServices.GetService() ?? + throw new InvalidOperationException("IIdeChannel is required"), + context.RequestServices); + + await server.RunAsync(await context.WebSockets.AcceptWebSocketAsync(), + CancellationToken.None); + } + else { - var mvidValue = context.GetRouteValue("mvid")?.ToString(); - if (!Guid.TryParse(mvidValue, out var mvid)) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Invalid MVID - must be a valid GUID."); - return; - } - - string? platform = null; - var isDebug = false; - - // Query string support: ?platform=...&isDebug=true - if (context.Request.Query.TryGetValue("platform", out var p)) + if (app.Log().IsEnabled(LogLevel.Error)) { - platform = p.ToString(); + app.Log().LogError($"Unable to find configuration service"); } + } + } + finally + { + // Track client disconnection in telemetry + var connectionContext = context.RequestServices.GetService(); + var telemetry = context.RequestServices.GetService(); + if (telemetry != null && connectionContext != null) + { + var connectionDuration = DateTimeOffset.UtcNow - connectionContext.ConnectedAt; - if (context.Request.Query.TryGetValue("isDebug", out var d)) + var properties = new Dictionary { - if (bool.TryParse(d.ToString(), out var qParsed)) - { - isDebug = qParsed; - } - } + ["ConnectionId"] = connectionContext.ConnectionId + }; - if (string.IsNullOrWhiteSpace(platform)) + var measurements = new Dictionary { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing required 'platform'."); - return; - } - - var monitor = context.RequestServices.GetRequiredService(); - monitor.RegisterLaunch(mvid, platform!, isDebug); + ["ConnectionDurationSeconds"] = connectionDuration.TotalSeconds + }; - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync("registered"); - }); + telemetry.TrackEvent("client-connection-closed", properties, measurements); + } - // Alternate HTTP GET endpoint to register an app launch by providing the absolute assembly file path with encoded path string - // Example: /app-launch/asm/C%3A%5Cpath%5Cto%5Capp.dll?IsDebug=true - router.MapGet("applaunch/asm/{*assemblyPath}", async context => + if (app.Log().IsEnabled(LogLevel.Information)) { - var assemblyPathValue = Uri.UnescapeDataString(context.GetRouteValue("assemblyPath")?.ToString() ?? string.Empty); - if (string.IsNullOrWhiteSpace(assemblyPathValue)) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Missing assembly path."); - return; - } + app.Log().LogInformation( + "Disposing connection from {ConnectionRemoteIpAddress}", context.Connection.RemoteIpAddress); + } + } + } - // On Windows, the route will capture slashes; ensure it is a full path - var assemblyPath = assemblyPathValue!; - if (!Path.IsPathRooted(assemblyPath) || !File.Exists(assemblyPath)) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("Assembly path must be an existing absolute path."); - return; - } + private static async Task HandleAppLaunchRegistrationRequest( + HttpContext context, + Guid mvid, + string? platform, + bool? isDebug) + { + if (platform == string.Empty) + { + platform = null; + } - var isDebug = false; - if (context.Request.Query.TryGetValue("isDebug", out var isDebugVal)) - { - if (!bool.TryParse(isDebugVal.ToString(), out isDebug)) - { - isDebug = false; - } - } + var monitor = context.RequestServices.GetRequiredService(); + monitor.RegisterLaunch(mvid, platform, isDebug ?? false); - try - { - // Read MVID and TargetPlatform without loading the assembly - var (mvid, platform) = AssemblyInfoReader.Read(assemblyPath); + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync("registered"); + } - var monitor = context.RequestServices.GetRequiredService(); - monitor.RegisterLaunch(mvid, platform ?? "Desktop", isDebug); + private static async Task HandleAppLaunchRegistrationRequest( + WebApplication app, + string assemblyPath, + HttpContext context, + bool? isDebug) + { + // Decode the path, if necessary (can be url-encoded) + assemblyPath = Uri.UnescapeDataString(assemblyPath); - context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync("registered - application with MVID=" + mvid + " and platform=" + platform + " is now registered for launch."); - } - catch (BadImageFormatException) - { - context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsync("The specified file is not a valid .NET assembly."); - } - catch (Exception ex) - { - if (app.Log().IsEnabled(LogLevel.Error)) - { - app.Log().LogError(ex, "Failed to read assembly info for path: {path}", assemblyPath); - } + // On Windows, the route will capture slashes; ensure it is a full path + if (!Path.IsPathRooted(assemblyPath) || !File.Exists(assemblyPath)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("Assembly path must be an existing absolute path."); + return; + } - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - await context.Response.WriteAsync("Failed to process assembly path."); - } - }); - }); + try + { + // Read MVID and TargetPlatform without loading the assembly + var (mvid, platform) = AssemblyInfoReader.Read(assemblyPath); + var monitor = context.RequestServices.GetRequiredService(); + monitor.RegisterLaunch(mvid, platform, isDebug ?? false); - return app; + context.Response.StatusCode = StatusCodes.Status200OK; + await context.Response.WriteAsync( + $"registered - application with MVID={mvid} and platform={platform} is now registered for launch."); + } + catch (BadImageFormatException) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsync("The specified file is not a valid .NET assembly."); + } + catch (Exception ex) + { + if (app.Log().IsEnabled(LogLevel.Error)) + { + app.Log().LogError(ex, "Failed to read assembly info for path: {path}", assemblyPath); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsync("Failed to process assembly path."); + } } } } diff --git a/src/Uno.UI.RemoteControl.Host/Startup.cs b/src/Uno.UI.RemoteControl.Host/Startup.cs index b85a8ca9a006..a56db46a42fd 100644 --- a/src/Uno.UI.RemoteControl.Host/Startup.cs +++ b/src/Uno.UI.RemoteControl.Host/Startup.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Uno.Extensions; @@ -21,18 +22,26 @@ public Startup(IConfiguration configuration) => public IConfiguration Configuration { get; } - public void Configure(IApplicationBuilder app, IOptionsMonitor optionsAccessor) + public void Configure(WebApplication app) { - var provider = new ServiceLocatorAdapter(app.ApplicationServices); + var services = app.Services; + + var provider = new ServiceLocatorAdapter(services); ServiceLocator.SetLocatorProvider(() => provider); - var options = optionsAccessor.CurrentValue; - app - .UseDeveloperExceptionPage() - .UseWebSockets() - .UseRemoteControlServer(options) - .UseRouting(); + var options = services.GetRequiredService>().CurrentValue; + + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseWebSockets(); + + // DevServer endpoints are registered here (http + websocket) + app.UseRemoteControlServer(options); + // CORS headers required for some platforms (WebAssembly) app.Use(async (context, next) => { context.Response.Headers.Append("Access-Control-Allow-Origin", "*"); diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 63efec1a30ed..6a0712a047fb 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -1,4 +1,5 @@ -using System; +#nullable enable +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; @@ -13,6 +14,8 @@ namespace Uno.UI.RemoteControl.Server.AppLaunch; /// public sealed class ApplicationLaunchMonitor : IDisposable { + private const string DefaultPlatform = "Unknown"; + /// /// Options that control the behavior of . /// @@ -110,8 +113,9 @@ public ApplicationLaunchMonitor(TimeProvider? timeProvider = null, Options? opti /// The MVID of the root/head application. /// The platform used to run the application. Cannot be null or empty. /// Whether the debugger is used. - public void RegisterLaunch(Guid mvid, string platform, bool isDebug) + public void RegisterLaunch(Guid mvid, string? platform, bool isDebug) { + platform ??= DefaultPlatform; ArgumentException.ThrowIfNullOrEmpty(platform); var now = _timeProvider.GetUtcNow(); @@ -122,7 +126,7 @@ public void RegisterLaunch(Guid mvid, string platform, bool isDebug) queue.Enqueue(ev); // Schedule automatic timeout - ScheduleTimeout(ev, key); + ScheduleAppLaunchTimeout(ev, key); try { @@ -139,7 +143,7 @@ public void RegisterLaunch(Guid mvid, string platform, bool isDebug) /// /// The launch event to schedule timeout for. /// The key for the launch event. - private void ScheduleTimeout(LaunchEvent launchEvent, Key key) + private void ScheduleAppLaunchTimeout(LaunchEvent launchEvent, Key key) { // Create a one-shot timer using the injected TimeProvider. When it fires, it will invoke HandleTimeout. var timer = _timeProvider.CreateTimer( @@ -198,8 +202,9 @@ private void HandleTimeout(LaunchEvent launchEvent, Key key) /// The MVID of the root/head application being connected. /// The name of the platform from which the connection is reported. Cannot be null or empty. /// true if the connection is from a debug build; otherwise, false. - public bool ReportConnection(Guid mvid, string platform, bool isDebug) + public bool ReportConnection(Guid mvid, string? platform, bool isDebug) { + platform ??= DefaultPlatform; ArgumentException.ThrowIfNullOrEmpty(platform); var key = new Key(mvid, platform, isDebug); diff --git a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs index 71e2e86a0aa0..b665db63152b 100644 --- a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs +++ b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; @@ -34,7 +35,10 @@ public IdeChannelClient(Guid pipeGuid, ILogger logger) public void ConnectToHost() { _IDEChannelCancellation?.Cancel(); - _IDEChannelCancellation = new CancellationTokenSource(); + var cts = new CancellationTokenSource(); + _IDEChannelCancellation = cts; + + var ct = cts.Token; _connectTask = Task.Run(async () => { @@ -48,18 +52,18 @@ public void ConnectToHost() _logger.Debug($"Creating IDE Channel to Dev Server ({_pipeGuid})"); - await _pipeServer.ConnectAsync(_IDEChannelCancellation.Token); + await _pipeServer.ConnectAsync(ct); _devServer = JsonRpc.Attach(_pipeServer); _devServer.MessageFromDevServer += ProcessDevServerMessage; - _ = Task.Run(StartKeepAliveAsync); + ScheduleKeepAlive(ct); } catch (Exception e) { _logger.Error($"Error creating IDE channel: {e}"); } - }, _IDEChannelCancellation.Token); + }, ct); } public async Task SendToDevServerAsync(IdeMessage message, CancellationToken ct) @@ -70,19 +74,27 @@ public async Task SendToDevServerAsync(IdeMessage message, CancellationToken ct) ? CancellationTokenSource.CreateLinkedTokenSource(ct, _IDEChannelCancellation!.Token).Token : _IDEChannelCancellation!.Token; await _devServer.SendToDevServerAsync(IdeMessageSerializer.Serialize(message), ct); + ScheduleKeepAlive(_IDEChannelCancellation!.Token); } } - private async Task StartKeepAliveAsync() + private const int KeepAliveDelay = 10000; // 10 seconds in milliseconds + private Timer? _keepAliveTimer; + + private void ScheduleKeepAlive(CancellationToken ct) { Connected?.Invoke(this, EventArgs.Empty); - while (_IDEChannelCancellation is { IsCancellationRequested: false }) + _keepAliveTimer?.Dispose(); + _keepAliveTimer = new Timer(_ => { - await _devServer!.SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), default); - - await Task.Delay(5000, _IDEChannelCancellation.Token); - } + if (ct is { IsCancellationRequested: false } && _devServer is not null) + { + _keepAliveTimer!.Dispose(); + var t = _devServer.SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), ct); + ScheduleKeepAlive(_IDEChannelCancellation!.Token); + } + }, null, KeepAliveDelay, Timeout.Infinite); } private void ProcessDevServerMessage(object sender, IdeMessageEnvelope devServerMessageEnvelope) @@ -93,13 +105,11 @@ private void ProcessDevServerMessage(object sender, IdeMessageEnvelope devServer var devServerMessage = IdeMessageSerializer.Deserialize(devServerMessageEnvelope); - _logger.Verbose($"IDE: IDEChannel message received {devServerMessage}"); - var process = Task.CompletedTask; switch (devServerMessage) { - case KeepAliveIdeMessage: - _logger.Verbose($"Keep alive from Dev Server"); + case KeepAliveIdeMessage ka: + _logger.Verbose($"Keep alive from {ka.Source}"); break; case IdeMessage message: _logger.Verbose($"Dev Server Message {message.GetType()} requested"); @@ -125,6 +135,7 @@ private void ProcessDevServerMessage(object sender, IdeMessageEnvelope devServer internal void Dispose() { _IDEChannelCancellation?.Cancel(); + _keepAliveTimer?.Dispose(); _pipeServer?.Dispose(); } } diff --git a/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs b/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs index 087999333401..1edeac38f4ed 100644 --- a/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs +++ b/src/Uno.UI.RemoteControl/Helpers/ApplicationInfoHelper.cs @@ -11,8 +11,8 @@ internal static class ApplicationInfoHelper /// /// Gets the target platform value from the assembly's TargetPlatformAttribute when present; otherwise returns the provided default ("Desktop" by default). /// - public static string GetTargetPlatformOrDefault(Assembly assembly, string @default = "Desktop") - => assembly.GetCustomAttribute()?.PlatformName ?? @default; + public static string? GetTargetPlatform(Assembly assembly) + => assembly.GetCustomAttribute()?.PlatformName; /// /// Returns the MVID (Module Version Id) of the given assembly. diff --git a/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs b/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs index b3b2abba44cc..c279c3fa2d6b 100644 --- a/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs +++ b/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs @@ -12,7 +12,7 @@ public record AppLaunchMessage : IMessage public Guid Mvid { get; init; } - public string Platform { get; init; } = string.Empty; + public string? Platform { get; init; } public bool IsDebug { get; init; } diff --git a/src/Uno.UI.RemoteControl/RemoteControlClient.cs b/src/Uno.UI.RemoteControl/RemoteControlClient.cs index e6b192349f18..49e6d516ddc1 100644 --- a/src/Uno.UI.RemoteControl/RemoteControlClient.cs +++ b/src/Uno.UI.RemoteControl/RemoteControlClient.cs @@ -729,7 +729,7 @@ public async Task SendAppIdentityAsync() { var asm = AppType.Assembly; var mvid = ApplicationInfoHelper.GetMvid(asm); - var platform = ApplicationInfoHelper.GetTargetPlatformOrDefault(asm); + var platform = ApplicationInfoHelper.GetTargetPlatform(asm); var isDebug = Debugger.IsAttached; await SendMessage(new AppLaunchMessage { Mvid = mvid, Platform = platform, IsDebug = isDebug, Step = AppLaunchStep.Connected }); From a3e71492cbb6da8809fb948e383d1238e72d6fe0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 14 Oct 2025 22:41:02 -0400 Subject: [PATCH 27/49] feat(app-launch): Add IDE and plugin metadata support across launch registration and telemetry --- .../AppLaunch/AppLaunchIntegrationTests.cs | 2 +- .../Given_ApplicationLaunchMonitor.cs | 34 +++++++-------- .../RemoteControlExtensions.cs | 24 +++++++---- .../RemoteControlServer.cs | 43 +++++++++++++------ src/Uno.UI.RemoteControl.Host/Telemetry.md | 42 +++++++++--------- .../IDEChannel/AppLaunchRegisterIdeMessage.cs | 3 +- .../AppLaunch/ApplicationLaunchMonitor.cs | 14 ++++-- .../AppLaunch/ApplicationLaunchMonitor.md | 13 ++---- .../Helpers/ServiceCollectionExtensions.cs | 25 ++++++----- .../AppLaunch/VsAppLaunchStateConsumer.cs | 25 +++++++++-- src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 10 +++-- .../Messages/AppLaunchMessage.cs | 4 ++ 12 files changed, 148 insertions(+), 91 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index c145768a7924..17d0200eea62 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -174,7 +174,7 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeC { await ide.EnsureConnectedAsync(CT); await Task.Delay(1500, CT); - await ide.SendToDevServerAsync(new AppLaunchRegisterIdeMessage(mvid, platform, isDebug), CT); + await ide.SendToDevServerAsync(new AppLaunchRegisterIdeMessage(mvid, platform, isDebug, "UnitTestIDE", "unit-plugin"), CT); } // ACT - STEP 2: Connect from application (app -> dev server) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index f6df4bb842b6..4e0ce4e2fc3b 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -59,7 +59,7 @@ public void WhenLaunchRegisteredAndNotTimedOut_ThenRegisteredCallbackOnly() var mvid = Guid.NewGuid(); // Act - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); clock.Advance(TimeSpan.FromSeconds(5)); // Assert @@ -79,7 +79,7 @@ public void WhenMatchingConnectionReportedForRegisteredLaunch_ThenConnectedInvok out var connections, out _); var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); // Act sut.ReportConnection(mvid, "Wasm", isDebug: true); @@ -108,14 +108,14 @@ public void WhenConnectionsArriveForMultipleRegistrations_ThenFifoOrderIsPreserv var mvid = Guid.NewGuid(); // First registration - will be expired - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); // Advance beyond timeout so the first one expires clock.Advance(TimeSpan.FromMilliseconds(600)); // Two active registrations that should remain in FIFO order - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); clock.Advance(TimeSpan.FromMilliseconds(1)); - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); // Act sut.ReportConnection(mvid, "Wasm", isDebug: true); @@ -147,7 +147,7 @@ public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stres // Register K entries which will be expired for (var i = 0; i < K; i++) { - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); clock.Advance(TimeSpan.FromTicks(1)); } @@ -157,7 +157,7 @@ public void WhenManyRegistrationsWithMixedTimeouts_FIFOOrderStillPreserved_Stres // Register L active entries without advancing the global clock so they remain within the timeout window for (var i = 0; i < L; i++) { - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); clock.Advance(TimeSpan.FromTicks(1)); // Do NOT advance the clock here: advancing would make early active entries expire before we report connections. } @@ -190,7 +190,7 @@ public void WhenRegisteredLaunchTimeoutExpires_ThenTimeoutCallbackInvoked() out _, timeout: TimeSpan.FromSeconds(10)); var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); // Act clock.Advance(TimeSpan.FromSeconds(11)); @@ -210,9 +210,9 @@ public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemove out var connections, out _); var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // will expire + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); // will expire clock.Advance(TimeSpan.FromSeconds(5)); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); // still active + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); // still active // Act clock.Advance(TimeSpan.FromSeconds(6)); // first expired, second still active @@ -230,7 +230,7 @@ public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentExcept using var sut = CreateMonitor(out _, out _, out _, out _, out _); var mvid = Guid.NewGuid(); - sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true)).Should().Throw(); + sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true, ide: "UnitTestIDE", plugin: "unit-plugin")).Should().Throw(); sut.Invoking(m => m.ReportConnection(mvid, string.Empty, true)).Should().Throw(); } @@ -239,7 +239,7 @@ public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() { using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", true); + sut.RegisterLaunch(mvid, "Wasm", true, ide: "UnitTestIDE", plugin: "unit-plugin"); clock.Advance(TimeSpan.FromSeconds(1)); // bellow timeout sut.ReportConnection(mvid, "wasm", true); @@ -256,7 +256,7 @@ public void ReportConnection_ReturnsBooleanIndicatingMatch() sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); // Register and then report -> true - sut.RegisterLaunch(mvid, "Wasm", isDebug: true); + sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeTrue(); // Already consumed -> false @@ -274,7 +274,7 @@ public void WhenConnectionArrivesAfterTimeout_ThenStillConnects() out _, timeout: TimeSpan.FromSeconds(5)); var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); // Advance past timeout so OnTimeout is invoked clock.Advance(TimeSpan.FromSeconds(6)); @@ -303,7 +303,7 @@ public void WhenScavengerRunsRepeatedly_ThenVeryOldEntriesAreRemoved() scavengeInterval: TimeSpan.FromSeconds(1)); var mvid = Guid.NewGuid(); - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); // Advance time past retention and run multiple scavenge intervals to ensure the periodic scavenge executed more than once clock.Advance(TimeSpan.FromSeconds(3)); // now > retention @@ -332,11 +332,11 @@ public void WhenScavengerRunsMultipleTimes_ThenOnlyOldEntriesRemovedAndNewerRema var mvid = Guid.NewGuid(); // Register first entry - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); clock.Advance(TimeSpan.FromSeconds(1)); // Register second entry (newer) - sut.RegisterLaunch(mvid, "Wasm", isDebug: false); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); // We only advance enough time so multiple scavenge passes run (scavenge interval = 1s) // but the newer entry (registered 1s after the first) is still within the retention window. diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 96a59a972797..1ac9a65cd2e0 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -35,9 +35,11 @@ public static WebApplication UseRemoteControlServer( // HTTP GET endpoint to register an app launch app.MapGet( "/applaunch/{mvid:guid}", - async (HttpContext context, Guid mvid, [FromQuery] string? platform, [FromQuery] bool? isDebug) => + async (HttpContext context, Guid mvid, [FromQuery] string? platform, [FromQuery] bool? isDebug, [FromQuery] string? ide, [FromQuery] string? plugin) => { - await HandleAppLaunchRegistrationRequest(context, mvid, platform, isDebug); + ide ??= "Unknown"; + plugin ??= "Unknown"; + await HandleAppLaunchRegistrationRequest(context, mvid, platform, isDebug, ide, plugin); }) .WithName("AppLaunchRegistration"); @@ -45,9 +47,11 @@ public static WebApplication UseRemoteControlServer( // Example: /app-launch/asm/C%3A%5Cpath%5Cto%5Capp.dll?IsDebug=true app.MapGet( "/applaunch/asm/{*assemblyPath}", - async (HttpContext context, string assemblyPath, [FromQuery] bool? isDebug) => + async (HttpContext context, string assemblyPath, [FromQuery] bool? isDebug, [FromQuery] string? ide, [FromQuery] string? plugin) => { - await HandleAppLaunchRegistrationRequest(app, assemblyPath, context, isDebug); + ide ??= "Unknown"; + plugin ??= "Unknown"; + await HandleAppLaunchRegistrationRequest(app, assemblyPath, context, isDebug, ide, plugin); }) .WithName("AppLaunchRegistration(Assembly)"); return app; @@ -147,7 +151,9 @@ private static async Task HandleAppLaunchRegistrationRequest( HttpContext context, Guid mvid, string? platform, - bool? isDebug) + bool? isDebug, + string ide, + string plugin) { if (platform == string.Empty) { @@ -155,7 +161,7 @@ private static async Task HandleAppLaunchRegistrationRequest( } var monitor = context.RequestServices.GetRequiredService(); - monitor.RegisterLaunch(mvid, platform, isDebug ?? false); + monitor.RegisterLaunch(mvid, platform, isDebug ?? false, ide, plugin); context.Response.StatusCode = StatusCodes.Status200OK; await context.Response.WriteAsync("registered"); @@ -165,7 +171,9 @@ private static async Task HandleAppLaunchRegistrationRequest( WebApplication app, string assemblyPath, HttpContext context, - bool? isDebug) + bool? isDebug, + string ide, + string plugin) { // Decode the path, if necessary (can be url-encoded) assemblyPath = Uri.UnescapeDataString(assemblyPath); @@ -184,7 +192,7 @@ private static async Task HandleAppLaunchRegistrationRequest( var (mvid, platform) = AssemblyInfoReader.Read(assemblyPath); var monitor = context.RequestServices.GetRequiredService(); - monitor.RegisterLaunch(mvid, platform, isDebug ?? false); + monitor.RegisterLaunch(mvid, platform, isDebug ?? false, ide, plugin); context.Response.StatusCode = StatusCodes.Status200OK; await context.Response.WriteAsync( diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index 7daeeee04427..8512b860ba17 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -255,11 +255,16 @@ private void ProcessIdeMessage(object? sender, IdeMessage message) { if (this.Log().IsEnabled(LogLevel.Debug)) { - this.Log().LogDebug("Received app launch register message from IDE, mvid={Mvid}, platform={Platform}, isDebug={IsDebug}", appLaunchRegisterIdeMessage.Mvid, appLaunchRegisterIdeMessage.Platform, appLaunchRegisterIdeMessage.IsDebug); + this.Log().LogDebug("Received app launch register message from IDE: {Msg}", appLaunchRegisterIdeMessage); } var monitor = _serviceProvider.GetRequiredService(); - monitor.RegisterLaunch(appLaunchRegisterIdeMessage.Mvid, appLaunchRegisterIdeMessage.Platform, appLaunchRegisterIdeMessage.IsDebug); + monitor.RegisterLaunch( + appLaunchRegisterIdeMessage.Mvid, + appLaunchRegisterIdeMessage.Platform, + appLaunchRegisterIdeMessage.IsDebug, + appLaunchRegisterIdeMessage.Ide, + appLaunchRegisterIdeMessage.Plugin); } else if (_processors.TryGetValue(message.Scope, out var processor)) { @@ -294,27 +299,41 @@ private async Task ProcessAppLaunchFrame(Frame frame) { if (this.Log().IsEnabled(LogLevel.Debug)) { - this.Log().LogDebug("App {Step}: MVID={Mvid} Platform={Platform} Debug={Debug}", appLaunch.Step, appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + this.Log().LogDebug("App {Step}: {Msg}", appLaunch.Step, appLaunch); } + var monitor = _serviceProvider.GetRequiredService(); + switch (appLaunch.Step) { case AppLaunchStep.Launched: - if (_serviceProvider.GetService() is { } monitor) + if (appLaunch.Ide is null) + { + if (this.Log().IsEnabled(LogLevel.Error)) + { + this.Log().LogError("App Launched: MVID={Mvid} Platform={Platform} Debug={Debug} - No IDE provided.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + } + break; + } + + if (appLaunch.Plugin is null) { - monitor.RegisterLaunch(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + if (this.Log().IsEnabled(LogLevel.Error)) + { + this.Log().LogError("App Launched: MVID={Mvid} Platform={Platform} Debug={Debug} - No Plugin provided.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + } + break; } + monitor.RegisterLaunch(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug, appLaunch.Ide, appLaunch.Plugin); break; + case AppLaunchStep.Connected: - if (_serviceProvider.GetService() is { } monitor2) + var success = monitor.ReportConnection(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); + if (!success) { - var success = monitor2.ReportConnection(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); - if (!success) + if (this.Log().IsEnabled(LogLevel.Error)) { - if (this.Log().IsEnabled(LogLevel.Error)) - { - this.Log().LogError("App Connected: MVID={Mvid} Platform={Platform} Debug={Debug} - Failed to report connected: APP LAUNCH NOT REGISTERED.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); - } + this.Log().LogError("App Connected: MVID={Mvid} Platform={Platform} Debug={Debug} - Failed to report connected: APP LAUNCH NOT REGISTERED.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); } } break; diff --git a/src/Uno.UI.RemoteControl.Host/Telemetry.md b/src/Uno.UI.RemoteControl.Host/Telemetry.md index 56a6f1e50c94..328a38685fa9 100644 --- a/src/Uno.UI.RemoteControl.Host/Telemetry.md +++ b/src/Uno.UI.RemoteControl.Host/Telemetry.md @@ -4,27 +4,27 @@ This table lists all telemetry events emitted by the Uno DevServer, with their p Event name prefix: uno/dev-server -| Event Name | Properties (string, no prefix) | Measurements (double, with prefixes) | Sensitive / Notes | Scope | -|-------------------------------------|-------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|----------------| -| **startup** | StartupHasSolution | | | Global | -| **shutdown** | ShutdownType ("Graceful"/"Crash") | UptimeSeconds | | Global | -| **startup-failure** | StartupErrorMessage, StartupErrorType, StartupStackTrace | UptimeSeconds | ErrorMessage/StackTrace may be sensitive (not anonymized) | Global | -| **parent-process-lost** | | | Emitted when parent process is lost, graceful shutdown is attempted. No properties. | Global | -| **parent-process-lost-forced-exit** | | | Emitted if forced exit after graceful shutdown timeout. No properties. | Global | -| **addin-discovery-start** | | | | Global | -| **addin-discovery-complete** | DiscoveryResult, DiscoveryAddInList | DiscoveryAddInCount, DiscoveryDurationMs | AddInList: filenames only | Global | -| **addin-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs | ErrorMessage may be sensitive (not anonymized) | Global | -| **addin-loading-start** | AssemblyList | | AssemblyList: filenames only | Global | -| **addin-loading-complete** | AssemblyList, Result | DurationMs, FailedAssemblies | | Global | -| **addin-loading-error** | AssemblyList, ErrorMessage, ErrorType | DurationMs, FailedAssemblies | ErrorMessage may be sensitive (not anonymized) | Global | -| **processor-discovery-start** | AppInstanceId, DiscoveryIsFile | | | Per-connection | -| **processor-discovery.complete** | AppInstanceId, DiscoveryIsFile, DiscoveryResult, DiscoveryFailedProcessors | DiscoveryDurationMs, DiscoveryAssembliesProcessed, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | FailedProcessors: comma-separated type names | Per-connection | -| **processor-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs, DiscoveryAssembliesCount, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | ErrorMessage may be sensitive (not anonymized) | Per-connection | -| **client-connection-opened** | ConnectionId | | Metadata fields are anonymized | Per-connection | -| **client-connection-closed** | ConnectionId | ConnectionDurationSeconds | | Per-connection | -| **app-launch/launched** | platform, debug | | No identifiers (MVID not sent) | Global | -| **app-launch/connected** | platform, debug | latencyMs | No identifiers (MVID not sent) | Global | -| **app-launch/connection-timeout** | platform, debug | timeoutSeconds | No identifiers (MVID not sent) | Global | +| Event Name | Properties (string, no prefix) | Measurements (double, with prefixes) | Sensitive / Notes | Scope | +|-------------------------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|----------------| +| **startup** | StartupHasSolution | | | Global | +| **shutdown** | ShutdownType ("Graceful"/"Crash") | UptimeSeconds | | Global | +| **startup-failure** | StartupErrorMessage, StartupErrorType, StartupStackTrace | UptimeSeconds | ErrorMessage/StackTrace may be sensitive (not anonymized) | Global | +| **parent-process-lost** | | | Emitted when parent process is lost, graceful shutdown is attempted. No properties. | Global | +| **parent-process-lost-forced-exit** | | | Emitted if forced exit after graceful shutdown timeout. No properties. | Global | +| **addin-discovery-start** | | | | Global | +| **addin-discovery-complete** | DiscoveryResult, DiscoveryAddInList | DiscoveryAddInCount, DiscoveryDurationMs | AddInList: filenames only | Global | +| **addin-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs | ErrorMessage may be sensitive (not anonymized) | Global | +| **addin-loading-start** | AssemblyList | | AssemblyList: filenames only | Global | +| **addin-loading-complete** | AssemblyList, Result | DurationMs, FailedAssemblies | | Global | +| **addin-loading-error** | AssemblyList, ErrorMessage, ErrorType | DurationMs, FailedAssemblies | ErrorMessage may be sensitive (not anonymized) | Global | +| **processor-discovery-start** | AppInstanceId, DiscoveryIsFile | | | Per-connection | +| **processor-discovery.complete** | AppInstanceId, DiscoveryIsFile, DiscoveryResult, DiscoveryFailedProcessors | DiscoveryDurationMs, DiscoveryAssembliesProcessed, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | FailedProcessors: comma-separated type names | Per-connection | +| **processor-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs, DiscoveryAssembliesCount, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | ErrorMessage may be sensitive (not anonymized) | Per-connection | +| **client-connection-opened** | ConnectionId | | Metadata fields are anonymized | Per-connection | +| **client-connection-closed** | ConnectionId | ConnectionDurationSeconds | | Per-connection | +| **app-launch/launched** | Platform, Debug, Ide, Plugin | | No identifiers (MVID not sent) | Global | +| **app-launch/connected** | Platform, Debug, Ide, Plugin | LatencyMs | No identifiers (MVID not sent) | Global | +| **app-launch/connection-timeout** | Platform, Debug, Ide, Plugin | TimeoutSeconds | No identifiers (MVID not sent) | Global | ## Notes - ErrorMessage and StackTrace are sent as raw values and may contain sensitive information; handle with care. diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs index 2c5137d6ac3a..d78cd0cd7ec0 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/AppLaunchRegisterIdeMessage.cs @@ -1,3 +1,4 @@ +#nullable enable using System; namespace Uno.UI.RemoteControl.Messaging.IdeChannel; @@ -8,4 +9,4 @@ namespace Uno.UI.RemoteControl.Messaging.IdeChannel; /// The MVID (Module Version ID) of the head application assembly. /// The target platform (case-sensitive, e.g. "Wasm", "Android"). /// Whether the app was launched under a debugger (Debug configuration). -public record AppLaunchRegisterIdeMessage(Guid Mvid, string Platform, bool IsDebug) : IdeMessage(WellKnownScopes.DevServerChannel); +public record AppLaunchRegisterIdeMessage(Guid Mvid, string? Platform, bool IsDebug, string Ide, string Plugin) : IdeMessage(WellKnownScopes.DevServerChannel); diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 6a0712a047fb..0e7ec866b256 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -60,7 +60,13 @@ public class Options /// /// Describes a single launch event recorded by the monitor. /// - public sealed record LaunchEvent(Guid Mvid, string Platform, bool IsDebug, DateTimeOffset RegisteredAt); + public sealed record LaunchEvent( + Guid Mvid, + string Platform, + bool IsDebug, + string Ide, + string Plugin, + DateTimeOffset RegisteredAt); private readonly TimeProvider _timeProvider; private readonly Options _options; @@ -113,13 +119,15 @@ public ApplicationLaunchMonitor(TimeProvider? timeProvider = null, Options? opti /// The MVID of the root/head application. /// The platform used to run the application. Cannot be null or empty. /// Whether the debugger is used. - public void RegisterLaunch(Guid mvid, string? platform, bool isDebug) + /// The IDE used to launch the application. + /// The Uno plugin version used to launch the application. + public void RegisterLaunch(Guid mvid, string? platform, bool isDebug, string ide, string plugin) { platform ??= DefaultPlatform; ArgumentException.ThrowIfNullOrEmpty(platform); var now = _timeProvider.GetUtcNow(); - var ev = new LaunchEvent(mvid, platform, isDebug, now); + var ev = new LaunchEvent(mvid, platform, isDebug, ide, plugin, now); var key = new Key(mvid, platform, isDebug); var queue = _pending.GetOrAdd(key, static _ => new ConcurrentQueue()); diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md index 8c53443d4258..6c12897c1f09 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.md @@ -27,7 +27,8 @@ using var monitor = new ApplicationLaunchMonitor(options: options); ### 2) When you start a target app, register the launch: ```csharp -monitor.RegisterLaunch(mvid, "Wasm", isDebug: true); +// New signature includes IDE and Uno plugin version +monitor.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "VisualStudio", plugin: "uno-vs-extension-1.2.3"); ``` ### 3) When the app connects back to the dev server, report the connection: @@ -44,15 +45,7 @@ That’s it. The monitor pairs the connection with the oldest pending launch for - Always dispose the monitor (use "using" as shown). ## Analytics Events (emitted by dev-server) -When integrated in the dev-server, the monitor emits telemetry events (prefix `uno/dev-server/` omitted below): - -| Event Name | When | Properties | Measurements | -|------------------------------------|-------------------------------------------------------------|-------------------------|------------------| -| `app-launch/launched` | IDE registers a launch | platform, debug | (none) | -| `app-launch/connected` | Runtime connects and matches a pending registration | platform, debug | latencyMs | -| `app-launch/connection-timeout` | Registration expired without a matching runtime connection | platform, debug | timeoutSeconds | - -`latencyMs` is the elapsed time between registration and connection, measured internally. `timeoutSeconds` equals the configured timeout. +Events emitted by the monitor are prefixed with `uno/dev-server/app-launch/`. See `Telemetry.md` in the `.Host` project for more info. ## Integration points (IDE, WebSocket, HTTP) The dev-server can receive registration and connection events through multiple channels: diff --git a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs index 5a7c5d41905b..603e6c8ba464 100644 --- a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs +++ b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using System.IO; using System.Reflection; using Microsoft.Extensions.DependencyInjection; @@ -40,8 +39,10 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv launchOptions.OnRegistered = ev => { telemetry.TrackEvent("app-launch/launched", [ - ("platform", ev.Platform), - ("debug", ev.IsDebug.ToString()) + ("Platform", ev.Platform), + ("Debug", ev.IsDebug.ToString()), + ("Ide", ev.Ide), + ("Plugin", ev.Plugin), ], null); }; @@ -50,11 +51,13 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv var latencyMs = (DateTimeOffset.UtcNow - ev.RegisteredAt).TotalMilliseconds; telemetry.TrackEvent("app-launch/connected", [ - ("platform", ev.Platform), - ("debug", ev.IsDebug.ToString()), - ("wasTimedOut", wasTimedOut.ToString()) + ("Platform", ev.Platform), + ("Debug", ev.IsDebug.ToString()), + ("WasTimedOut", wasTimedOut.ToString()), + ("Ide", ev.Ide), + ("Plugin", ev.Plugin), ], - [("latencyMs", latencyMs)]); + [("LatencyMs", latencyMs)]); }; launchOptions.OnTimeout = ev => @@ -62,10 +65,12 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv var timeoutSeconds = launchOptions.Timeout.TotalSeconds; telemetry.TrackEvent("app-launch/connection-timeout", [ - ("platform", ev.Platform), - ("debug", ev.IsDebug.ToString()), + ("Platform", ev.Platform), + ("Debug", ev.IsDebug.ToString()), + ("Ide", ev.Ide), + ("Plugin", ev.Plugin), ], - [("timeoutSeconds", timeoutSeconds)]); + [("TimeoutSeconds", timeoutSeconds)]); }; return new AppLaunch.ApplicationLaunchMonitor(options: launchOptions); diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs index ce9f758b55b9..e299b227104a 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs @@ -17,27 +17,41 @@ internal sealed class VsAppLaunchStateConsumer : IDisposable private readonly AsyncPackage _package; private readonly VsAppLaunchStateService _stateService; private readonly Func _ideChannelAccessor; + private readonly string _packageVersion; private VsAppLaunchStateConsumer( AsyncPackage package, VsAppLaunchStateService stateService, - Func ideChannelAccessor) + Func ideChannelAccessor, + string packageVersion) { _package = package; _stateService = stateService; _ideChannelAccessor = ideChannelAccessor; + _packageVersion = packageVersion; } public static async Task CreateAsync( AsyncPackage package, VsAppLaunchStateService stateService, - Func ideChannelAccessor) + Func ideChannelAccessor, + string packageVersion) { - var c = new VsAppLaunchStateConsumer(package, stateService, ideChannelAccessor); + var c = new VsAppLaunchStateConsumer(package, stateService, ideChannelAccessor, packageVersion); await c.InitializeAsync(); return c; } + // Backward-compatible overload: compute package version when caller doesn't provide it. + public static Task CreateAsync( + AsyncPackage package, + VsAppLaunchStateService stateService, + Func ideChannelAccessor) + { + var packageVersion = typeof(VsAppLaunchStateConsumer).Assembly.GetName().Version?.ToString() ?? string.Empty; + return CreateAsync(package, stateService, ideChannelAccessor, packageVersion); + } + private Task InitializeAsync() { // Only subscribe; handling will run on the package's JoinableTaskFactory when needed @@ -75,7 +89,10 @@ private async Task ExtractAndSendAppLaunchInfoAsync(AppLaunchDetails details) var ideChannel = _ideChannelAccessor(); if (ideChannel != null && details.IsDebug is { } isDebug) { - var message = new AppLaunchRegisterIdeMessage(mvid, platform, isDebug); + // Provide IDE and plugin metadata. For Visual Studio host, report product name and unknown plugin version when not available. + var ideName = "VisualStudio"; + var pluginVersion = _packageVersion; + var message = new AppLaunchRegisterIdeMessage(mvid, platform, isDebug, ideName, pluginVersion); await ideChannel.SendToDevServerAsync(message, CancellationToken.None); } } diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index b80ab7767109..1d02e16578de 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -215,8 +215,10 @@ private async Task InitializeAppLaunchTrackingAsync(AsyncPackage asyncPackage) _debugAction?.Invoke($"[AppLaunch] {e.Previous} -> {e.Current} key={key}"); }; + var packageVersion = GetAssemblyVersion(); + _appLaunchIdeBridge = await VsAppLaunchIdeBridge.CreateAsync(asyncPackage, _dte2, stateService); - _appLaunchStateConsumer = await VsAppLaunchStateConsumer.CreateAsync(asyncPackage, stateService, () => _ideChannelClient); + _appLaunchStateConsumer = await VsAppLaunchStateConsumer.CreateAsync(asyncPackage, stateService, () => _ideChannelClient, packageVersion); } private Task> OnProvideGlobalPropertiesAsync() @@ -293,15 +295,15 @@ private void SetupOutputWindow() _infoAction($"Uno Remote Control initialized ({GetAssemblyVersion()})"); } - private object GetAssemblyVersion() + private string GetAssemblyVersion() { var assembly = GetType().GetTypeInfo().Assembly; - if (assembly.GetCustomAttribute() is AssemblyInformationalVersionAttribute aiva) + if (assembly.GetCustomAttribute() is { } aiva) { return aiva.InformationalVersion; } - else if (assembly.GetCustomAttribute() is AssemblyVersionAttribute ava) + else if (assembly.GetCustomAttribute() is { } ava) { return ava.Version; } diff --git a/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs b/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs index c279c3fa2d6b..cd317f5ffc03 100644 --- a/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs +++ b/src/Uno.UI.RemoteControl/Messages/AppLaunchMessage.cs @@ -16,6 +16,10 @@ public record AppLaunchMessage : IMessage public bool IsDebug { get; init; } + public string? Ide { get; init; } + + public string? Plugin { get; init; } + public required AppLaunchStep Step { get; init; } public string Scope => WellKnownScopes.DevServerChannel; From 90f0a5d2a60671e24352908dd101a50dd3d9f800 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 14 Oct 2025 23:26:55 -0400 Subject: [PATCH 28/49] feat(app-launch): Added new integration test to validate timeout behavior and corresponding telemetry event. --- .../AppLaunch/AppLaunchIntegrationTests.cs | 64 +++++++++++++++++++ .../Helpers/ServiceCollectionExtensions.cs | 7 ++ 2 files changed, 71 insertions(+) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index 17d0200eea62..5bfab4e5433e 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -210,6 +210,70 @@ [new ServerEndpointAttribute("localhost", helper.Port)], } } + [TestMethod] + public async Task WhenRegisteredButNoConnection_TimeoutEventEmitted() + { + // PRE-ARRANGE: Create a solution file + var solution = SolutionHelper!; + await solution.CreateSolutionFileAsync(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_timeout")); + + // Create helper with environment variable for short timeout (0.5 seconds) + var envVars = new Dictionary + { + { "UNO_PLATFORM_TELEMETRY_FILE", filePath }, + { "UNO_DEVSERVER_APPLAUNCH_TIMEOUT", "0.5" } // 0.5 second timeout + }; + + await using var helper = new DevServerTestHelper(Logger, solutionPath: solution.SolutionFile, environmentVariables: envVars); + + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + var asm = typeof(AppLaunchIntegrationTests).Assembly; + var mvid = ApplicationInfoHelper.GetMvid(asm); + var platform = ApplicationInfoHelper.GetTargetPlatform(asm) is { } p ? "&platform=" + Uri.EscapeDataString(p) : null; + var isDebug = Debugger.IsAttached; + + // ACT - STEP 1: Register app launch via HTTP GET (simulating IDE -> dev server) + using (var http = new HttpClient()) + { + var url = $"http://localhost:{helper.Port}/applaunch/{mvid}?isDebug={isDebug.ToString().ToLowerInvariant()}{platform}"; + var response = await http.GetAsync(url, CT); + var body = await response.Content.ReadAsStringAsync(); + response.EnsureSuccessStatusCode(); + } + + // ACT - STEP 2: Wait for timeout to occur + await Task.Delay(5_000, CT); // Wait 5 seconds (should be far more than enough for the timeout to occur) + + // ACT - STEP 3: Stop and gather telemetry events + await helper.AttemptGracefulShutdownAsync(CT); + + // ASSERT + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue(); + events.Should().NotBeEmpty(); + WriteEventsList(events); + AssertHasEvent(events, "uno/dev-server/app-launch/launched"); + AssertHasEvent(events, "uno/dev-server/app-launch/connection-timeout"); + + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); + } + finally + { + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path) => File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : []; diff --git a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs index 603e6c8ba464..f03aa8bbd4cc 100644 --- a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs +++ b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs @@ -36,6 +36,13 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv var telemetry = sp.GetRequiredService(); var launchOptions = new AppLaunch.ApplicationLaunchMonitor.Options(); + // Support for configuring timeout via environment variable + var timeoutEnv = Environment.GetEnvironmentVariable("UNO_DEVSERVER_APPLAUNCH_TIMEOUT"); + if (!string.IsNullOrEmpty(timeoutEnv) && double.TryParse(timeoutEnv, out var timeoutSeconds)) + { + launchOptions.Timeout = TimeSpan.FromSeconds(timeoutSeconds); + } + launchOptions.OnRegistered = ev => { telemetry.TrackEvent("app-launch/launched", [ From 4eca7199e146af2ee41d2ea41d5122fb07e84a90 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Oct 2025 10:56:00 -0400 Subject: [PATCH 29/49] feat(app-launch): Add UnoNotifyAppLaunch MSBuild target for IDE integration and improve response handling --- src/Uno.Sdk/targets/Uno.AppLaunch.targets | 174 ++++++++++++++++++ src/Uno.Sdk/targets/Uno.Build.targets | 3 + .../RemoteControlExtensions.cs | 15 +- 3 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/Uno.Sdk/targets/Uno.AppLaunch.targets diff --git a/src/Uno.Sdk/targets/Uno.AppLaunch.targets b/src/Uno.Sdk/targets/Uno.AppLaunch.targets new file mode 100644 index 000000000000..681df264fd03 --- /dev/null +++ b/src/Uno.Sdk/targets/Uno.AppLaunch.targets @@ -0,0 +1,174 @@ + + + + + + + $(UnoNotifyAppLaunchDependsOn);GetTargetPath + + + + + + + + + + + + + + (); + void Add(string name, string value) + { + if (!string.IsNullOrEmpty(value)) + parts.Add($"{name}={Uri.EscapeDataString(value)}"); + } + + Add("ide", Ide); + Add("plugin", Plugin); + + // Normalize IsDebug to "true"/"false" if it's a recognizable bool; otherwise pass as-is. + if (string.IsNullOrEmpty(IsDebug)) + { + Log.LogError("IsDebug is required."); + return Success = false; + } + + if (bool.TryParse(IsDebug, out var b)) + Add("isDebug", b ? "true" : "false"); + else + Add("isDebug", IsDebug); + + var qs = parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; + var url = $"http://localhost:{portNum}/applaunch/asm/{encodedPath}{qs}"; + + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var response = client.GetAsync(url).GetAwaiter().GetResult(); + + Log.LogMessage(MessageImportance.High, + $"[NotifyDevServer] GET {url} -> {(int)response.StatusCode} {response.ReasonPhrase}"); + + if (response.IsSuccessStatusCode) + { + ResponseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + Success = true; + return true; + } + else + { + Success = false; + ResponseContent = string.Empty; + return false; + } + } + catch (Exception ex) + { + Log.LogWarning($"[NotifyDevServer] GET {url} failed: {ex.GetType().Name}: {ex.Message}"); + Success = false; + ResponseContent = string.Empty; + return false; + } + } + } + ]]> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Uno.Sdk/targets/Uno.Build.targets b/src/Uno.Sdk/targets/Uno.Build.targets index 899c554e89af..04b5abb5eed3 100644 --- a/src/Uno.Sdk/targets/Uno.Build.targets +++ b/src/Uno.Sdk/targets/Uno.Build.targets @@ -183,4 +183,7 @@ + + + diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 1ac9a65cd2e0..214fbb9d0f45 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Threading; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,6 +14,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Uno.UI.RemoteControl.VS.Helpers; +using System.Text.Json; namespace Uno.UI.RemoteControl.Host { @@ -164,7 +164,11 @@ private static async Task HandleAppLaunchRegistrationRequest( monitor.RegisterLaunch(mvid, platform, isDebug ?? false, ide, plugin); context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync("registered"); + context.Response.ContentType = "application/json"; + + var response = new { mvid = mvid, targetFramework = platform ?? "unknown" }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); } private static async Task HandleAppLaunchRegistrationRequest( @@ -195,8 +199,11 @@ private static async Task HandleAppLaunchRegistrationRequest( monitor.RegisterLaunch(mvid, platform, isDebug ?? false, ide, plugin); context.Response.StatusCode = StatusCodes.Status200OK; - await context.Response.WriteAsync( - $"registered - application with MVID={mvid} and platform={platform} is now registered for launch."); + context.Response.ContentType = "application/json"; + + var response = new { mvid = mvid, targetFramework = platform ?? "unknown" }; + + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); } catch (BadImageFormatException) { From 60e4e53b29d296b8bff6807780593daca0badce3 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Oct 2025 11:52:10 -0400 Subject: [PATCH 30/49] fix(app-launch): Enforce Debug configuration for UnoNotifyAppLaunch target and improve error messaging --- src/Uno.Sdk/targets/Uno.AppLaunch.targets | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Uno.Sdk/targets/Uno.AppLaunch.targets b/src/Uno.Sdk/targets/Uno.AppLaunch.targets index 681df264fd03..0badc7def05b 100644 --- a/src/Uno.Sdk/targets/Uno.AppLaunch.targets +++ b/src/Uno.Sdk/targets/Uno.AppLaunch.targets @@ -11,16 +11,19 @@ a different mechanism to do the same thing. The implementation in this version is in the `AppLaunch` folder of the `Uno.UI.RemoteControl.VS` project. + IMPORTANT: This target should only be called with Debug configuration (-c:Debug or -p:Configuration=Debug). + It will fail with an error if called with any other configuration (e.g., Release). + Other IDEs will call this target by specifying the `UnoRemoteControlPort` property using the following command: ```shell - dotnet build -t:UnoNotifyAppLaunch -p:TargetFramework= -p:NoBuild=true -restore:false -v:d -noLogo -low -p:Ide= -p:Plugin= -p:IsDebug=false + dotnet build -c:Debug -t:UnoNotifyAppLaunch -p:TargetFramework= -p:NoBuild=true -restore:false -v:d -noLogo -low -p:Ide= -p:Plugin= -p:IsDebug=false ``` To retrieve the HTTP response content (MVID and target framework info), use -getProperty: ```shell - dotnet build -t:UnoNotifyAppLaunch -getProperty:UnoNotifyAppLaunchHttpResponse -p:TargetFramework= -p:NoBuild=true -restore:false -p:Ide= -p:Plugin= -p:IsDebug=false + dotnet build -c:Debug -t:UnoNotifyAppLaunch -getProperty:UnoNotifyAppLaunchHttpResponse -p:TargetFramework= -p:NoBuild=true -restore:false -p:Ide= -p:Plugin= -p:IsDebug=false ``` --> @@ -140,7 +143,10 @@ Condition="'$(TargetFramework)'!=''"> + Text="NotifyAppLaunch: TFM=$(TargetFramework) TargetPath=$(TargetPath) Configuration=$(Configuration)"/> + + @@ -159,15 +165,12 @@ - + - - From 3d89e97d12ff89b7efc7e947c37903e7a18f7760 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Oct 2025 14:16:51 -0400 Subject: [PATCH 31/49] fix(app-launch): Update telemetry event properties for clarity and consistency --- .../Helpers/ServiceCollectionExtensions.cs | 24 +++++++++---------- .../AppLaunch/VsAppLaunchStateConsumer.cs | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs index f03aa8bbd4cc..89b36481c977 100644 --- a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs +++ b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs @@ -46,10 +46,10 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv launchOptions.OnRegistered = ev => { telemetry.TrackEvent("app-launch/launched", [ - ("Platform", ev.Platform), - ("Debug", ev.IsDebug.ToString()), - ("Ide", ev.Ide), - ("Plugin", ev.Plugin), + ("TargetPlatform", ev.Platform), + ("IsDebug", ev.IsDebug.ToString()), + ("IDE", ev.Ide), + ("PluginVersion", ev.Plugin), ], null); }; @@ -58,11 +58,11 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv var latencyMs = (DateTimeOffset.UtcNow - ev.RegisteredAt).TotalMilliseconds; telemetry.TrackEvent("app-launch/connected", [ - ("Platform", ev.Platform), - ("Debug", ev.IsDebug.ToString()), + ("TargetPlatform", ev.Platform), + ("IsDebug", ev.IsDebug.ToString()), ("WasTimedOut", wasTimedOut.ToString()), - ("Ide", ev.Ide), - ("Plugin", ev.Plugin), + ("IDE", ev.Ide), + ("PluginVersion", ev.Plugin), ], [("LatencyMs", latencyMs)]); }; @@ -72,10 +72,10 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv var timeoutSeconds = launchOptions.Timeout.TotalSeconds; telemetry.TrackEvent("app-launch/connection-timeout", [ - ("Platform", ev.Platform), - ("Debug", ev.IsDebug.ToString()), - ("Ide", ev.Ide), - ("Plugin", ev.Plugin), + ("TargetPlatform", ev.Platform), + ("IsDebug", ev.IsDebug.ToString()), + ("IDE", ev.Ide), + ("PluginVersion", ev.Plugin), ], [("TimeoutSeconds", timeoutSeconds)]); }; diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs index e299b227104a..351a5ece8ffd 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs @@ -90,7 +90,7 @@ private async Task ExtractAndSendAppLaunchInfoAsync(AppLaunchDetails details) if (ideChannel != null && details.IsDebug is { } isDebug) { // Provide IDE and plugin metadata. For Visual Studio host, report product name and unknown plugin version when not available. - var ideName = "VisualStudio"; + var ideName = "vswin-"; var pluginVersion = _packageVersion; var message = new AppLaunchRegisterIdeMessage(mvid, platform, isDebug, ideName, pluginVersion); await ideChannel.SendToDevServerAsync(message, CancellationToken.None); From 1db41d4b4fdd61a4054c78d6405eb05f813c4d9b Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Oct 2025 14:17:17 -0400 Subject: [PATCH 32/49] feat(telemetry): Enhance telemetry documentation with source links and property examples --- src/Uno.UI.RemoteControl.Host/Telemetry.md | 64 +++++++++++++------ .../Telemetry.md | 47 +++++++++----- 2 files changed, 76 insertions(+), 35 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Host/Telemetry.md b/src/Uno.UI.RemoteControl.Host/Telemetry.md index 328a38685fa9..9f08042e5174 100644 --- a/src/Uno.UI.RemoteControl.Host/Telemetry.md +++ b/src/Uno.UI.RemoteControl.Host/Telemetry.md @@ -6,25 +6,51 @@ Event name prefix: uno/dev-server | Event Name | Properties (string, no prefix) | Measurements (double, with prefixes) | Sensitive / Notes | Scope | |-------------------------------------|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|----------------| -| **startup** | StartupHasSolution | | | Global | -| **shutdown** | ShutdownType ("Graceful"/"Crash") | UptimeSeconds | | Global | -| **startup-failure** | StartupErrorMessage, StartupErrorType, StartupStackTrace | UptimeSeconds | ErrorMessage/StackTrace may be sensitive (not anonymized) | Global | -| **parent-process-lost** | | | Emitted when parent process is lost, graceful shutdown is attempted. No properties. | Global | -| **parent-process-lost-forced-exit** | | | Emitted if forced exit after graceful shutdown timeout. No properties. | Global | -| **addin-discovery-start** | | | | Global | -| **addin-discovery-complete** | DiscoveryResult, DiscoveryAddInList | DiscoveryAddInCount, DiscoveryDurationMs | AddInList: filenames only | Global | -| **addin-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs | ErrorMessage may be sensitive (not anonymized) | Global | -| **addin-loading-start** | AssemblyList | | AssemblyList: filenames only | Global | -| **addin-loading-complete** | AssemblyList, Result | DurationMs, FailedAssemblies | | Global | -| **addin-loading-error** | AssemblyList, ErrorMessage, ErrorType | DurationMs, FailedAssemblies | ErrorMessage may be sensitive (not anonymized) | Global | -| **processor-discovery-start** | AppInstanceId, DiscoveryIsFile | | | Per-connection | -| **processor-discovery.complete** | AppInstanceId, DiscoveryIsFile, DiscoveryResult, DiscoveryFailedProcessors | DiscoveryDurationMs, DiscoveryAssembliesProcessed, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | FailedProcessors: comma-separated type names | Per-connection | -| **processor-discovery-error** | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs, DiscoveryAssembliesCount, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | ErrorMessage may be sensitive (not anonymized) | Per-connection | -| **client-connection-opened** | ConnectionId | | Metadata fields are anonymized | Per-connection | -| **client-connection-closed** | ConnectionId | ConnectionDurationSeconds | | Per-connection | -| **app-launch/launched** | Platform, Debug, Ide, Plugin | | No identifiers (MVID not sent) | Global | -| **app-launch/connected** | Platform, Debug, Ide, Plugin | LatencyMs | No identifiers (MVID not sent) | Global | -| **app-launch/connection-timeout** | Platform, Debug, Ide, Plugin | TimeoutSeconds | No identifiers (MVID not sent) | Global | +| **startup** [[src]](Program.cs#L187) | StartupHasSolution | | | Global | +| **shutdown** [[src]](Program.cs#L211) | ShutdownType | UptimeSeconds | | Global | +| **startup-failure** [[src]](Program.cs#L233) | StartupErrorMessage, StartupErrorType, StartupStackTrace | UptimeSeconds | ErrorMessage/StackTrace may be sensitive (not anonymized) | Global | +| **parent-process-lost** [[src]](ParentProcessObserver.cs#L38) | | | Emitted when parent process is lost, graceful shutdown is attempted. No properties. | Global | +| **parent-process-lost-forced-exit** [[src]](ParentProcessObserver.cs#L46) | | | Emitted if forced exit after graceful shutdown timeout. No properties. | Global | +| **addin-discovery-start** [[src]](Extensibility/AddIns.cs#L24) | | | | Global | +| **addin-discovery-complete** [[src]](Extensibility/AddIns.cs#L147) | DiscoveryResult, DiscoveryAddInList | DiscoveryAddInCount, DiscoveryDurationMs | AddInList: filenames only | Global | +| **addin-discovery-error** [[src]](Extensibility/AddIns.cs#L126) | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs | ErrorMessage may be sensitive (not anonymized) | Global | +| **addin-loading-start** [[src]](Helpers/AssemblyHelper.cs#L23) | AssemblyList | | AssemblyList: filenames only | Global | +| **addin-loading-complete** [[src]](Helpers/AssemblyHelper.cs#L65) | AssemblyList, Result | DurationMs, FailedAssemblies | | Global | +| **addin-loading-error** [[src]](Helpers/AssemblyHelper.cs#L83) | AssemblyList, ErrorMessage, ErrorType | DurationMs, FailedAssemblies | ErrorMessage may be sensitive (not anonymized) | Global | +| **processor-discovery-start** [[src]](RemoteControlServer.cs#L407) | AppInstanceId, DiscoveryIsFile | | | Per-connection | +| **processor-discovery-complete** [[src]](RemoteControlServer.cs#L579) | AppInstanceId, DiscoveryIsFile, DiscoveryResult, DiscoveryFailedProcessors | DiscoveryDurationMs, DiscoveryAssembliesProcessed, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | FailedProcessors: comma-separated type names | Per-connection | +| **processor-discovery-error** [[src]](RemoteControlServer.cs#L603) | DiscoveryErrorMessage, DiscoveryErrorType | DiscoveryDurationMs, DiscoveryAssembliesCount, DiscoveryProcessorsLoadedCount, DiscoveryProcessorsFailedCount | ErrorMessage may be sensitive (not anonymized) | Per-connection | +| **client-connection-opened** [[src]](RemoteControlExtensions.cs#L92) | ConnectionId | | Metadata fields are anonymized | Per-connection | +| **client-connection-closed** [[src]](RemoteControlExtensions.cs#L139) | ConnectionId | ConnectionDurationSeconds | | Per-connection | +| **app-launch/launched** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L48) | TargetPlatform, IsDebug, IDE, PluginVersion | | No identifiers (MVID not sent) | Global | +| **app-launch/connected** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L59) | TargetPlatform, IsDebug, IDE, PluginVersion, WasTimedOut | LatencyMs | No identifiers (MVID not sent) | Global | +| **app-launch/connection-timeout** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L73) | TargetPlatform, IsDebug, IDE, PluginVersion | TimeoutSeconds | No identifiers (MVID not sent) | Global | + +## Property Value Examples + +### String Properties +- **StartupHasSolution** +- **ShutdownType**: `"Graceful"`, `"Crash"` +- **StartupErrorMessage**: `"Failed to bind to address http://[::]:52186: address already..."`, `"Unable to resolve service for type 'Microsoft.Extensions.C"` +- **StartupErrorType**: `"MissingMethodException"`, `"IOException"`, `"InvalidOperationException"` +- **StartupStackTrace**: `"at Program.Main(String[] args) in Program.cs:line 123"` +- **DiscoveryResult**: `"Success"`, `"PartialFailure"`, `"NoTargetFrameworks"`, `"NoAddInsFound"` +- **DiscoveryAddInList**: `"Uno.UI.App.Mcp.Server.dll;Uno.Settings.DevServer.dll"` +- **DiscoveryIsFile** +- **DiscoveryErrorMessage**: `"Directory not found"`, `"Access denied"` +- **DiscoveryErrorType**: `"DirectoryNotFoundException"`, `"UnauthorizedAccessException"` +- **ErrorMessage**: `"Assembly load failed"`, `"Type not found"` +- **ErrorType**: `"FileLoadException"`, `"TypeLoadException"` +- **AppInstanceId**: `"abc123-def456"`, `"instance-789"` +- **DiscoveryIsFile** +- **DiscoveryFailedProcessors** +- **ConnectionId**: `"conn-abc123"`, `"conn-xyz789"` +- **TargetPlatform**: `"Desktop1.0"`, `"Android35.0"`, `"BrowerWasm1.0"`, `"iOS18.5"`... +- **IsDebug**: `"True"`, `"False"` +- **IDE**: `"vswin"`, `"rider-2025.2.0.1"`, `"vscode-1.105.0"`, `"Unknown"` +- **PluginVersion**: `"1.0.0"`, `"2.1.5"` +- **WasTimedOut**: `"True"`, `"False"` ## Notes - ErrorMessage and StackTrace are sent as raw values and may contain sensitive information; handle with care. + diff --git a/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md b/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md index 739218af746f..1ef0f18a1421 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md +++ b/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md @@ -6,22 +6,37 @@ Event name prefix: uno/dev-server/hot-reload | Event Name | Main Properties (with hotreload/ prefix) | Measurements (with hotreload/ prefix) | |-------------------------|-------------------------------------------------------|---------------------------------------| -| notify-start | Event, Source, PreviousState | FileCount | -| notify-disabled | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount | -| notify-initializing | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount | -| notify-ready | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount | -| notify-processing-files | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount | -| notify-completed | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount, DurationMs | -| notify-no-changes | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount, DurationMs | -| notify-failed | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount, DurationMs | -| notify-rude-edit | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount, DurationMs | -| notify-complete | Event, Source, PreviousState, NewState, HasCurrentOp | FileCount, DurationMs | -| notify-error | Event, Source, PreviousState, ErrorMessage, ErrorType | FileCount, DurationMs | +| **notify-start** [[src]](HotReload/ServerHotReloadProcessor.cs#L158) | Event, Source, PreviousState | FileCount | +| **notify-disabled** [[src]](HotReload/ServerHotReloadProcessor.cs#L168) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | +| **notify-initializing** [[src]](HotReload/ServerHotReloadProcessor.cs#L174) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | +| **notify-ready** [[src]](HotReload/ServerHotReloadProcessor.cs#L180) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | +| **notify-processing-files** [[src]](HotReload/ServerHotReloadProcessor.cs#L186) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | +| **notify-completed** [[src]](HotReload/ServerHotReloadProcessor.cs#L191) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | +| **notify-no-changes** [[src]](HotReload/ServerHotReloadProcessor.cs#L196) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | +| **notify-failed** [[src]](HotReload/ServerHotReloadProcessor.cs#L201) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | +| **notify-rude-edit** [[src]](HotReload/ServerHotReloadProcessor.cs#L206) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | +| **notify-complete** [[src]](HotReload/ServerHotReloadProcessor.cs#L212) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | +| **notify-error** [[src]](HotReload/ServerHotReloadProcessor.cs#L221) | Event, Source, PreviousState, ErrorMessage, ErrorType | FileCount, DurationMs | -## Property details -- `FileCount`: Number of files affected by the operation -- `DurationMs`: Duration of the operation in milliseconds (if applicable) -- `HasCurrentOp`: Indicates if a Hot Reload operation is in progress -- `ErrorMessage`/`ErrorType`: Only present on `.Error` events +## Property Value Examples + +### String Properties +- **Event**: `"ProcessingFiles"`, `"Completed"`, ... +- **Source**: `"IDE"`, `"DevServer"`, ... +- **PreviousState**: `"Ready"`, `"Disabled"`, `"Initializing"`, `"Processing"` +- **NewState**: `"Ready"`, `"Disabled"`, `"Initializing"`, `"Processing"` +- **HasCurrentOperation** +- **ErrorMessage**: `"Compilation failed"`, `"Syntax error"` +- **ErrorType**: `"CompilationException"`, `"SyntaxException"` + +## Property Details +- **Event**: The type of event that triggered the notification +- **Source**: The source of the event +- **PreviousState**: The state before the operation +- **NewState**: The state after the operation (when applicable) +- **HasCurrentOperation**: Indicates if a Hot Reload operation is in progress +- **FileCount**: Number of files affected by the operation +- **DurationMs**: Duration of the operation in milliseconds (if applicable) +- **ErrorMessage**/**ErrorType**: Only present on error events All events are tracked server-side in `Notify()`. From 7be8c13d6929cb21b041e5ddb4ace232415d08ce Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 17 Oct 2025 14:57:40 -0400 Subject: [PATCH 33/49] fix(logs): Suppress WebSocket closure noise in logs --- src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index 8512b860ba17..44e28678f9c7 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -238,6 +238,11 @@ public async Task RunAsync(WebSocket socket, CancellationToken ct) } } } + catch(WebSocketException) when (socket.State == WebSocketState.Closed) + { + // Ignore "The remote party closed the WebSocket connection without completing the close handshake." + // It's making noise in the logs. + } catch (Exception error) { if (this.Log().IsEnabled(LogLevel.Error)) From 8c5b065aa6a66858cfe6c8cfdaa2037bd4fc12dc Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 21 Oct 2025 22:52:06 -0400 Subject: [PATCH 34/49] feat(app-launch): Improve late-match classification for app launches - Added retry mechanism to classify pending app launches as IDE-initiated when registered late. - Enhanced connection handling to account for unsolicited connections with non-IDE classification. - Updated application monitoring options to support pending retry windows and retention. - Introduced integration tests to validate pending connection classification behavior. --- .../AppLaunch/AppLaunchIntegrationTests.cs | 124 ++++++++++ .../Given_ApplicationLaunchMonitor.cs | 139 +++++++++-- .../Telemetry/TelemetryTestBase.cs | 26 ++ ...no.UI.RemoteControl.DevServer.Tests.csproj | 13 +- .../RemoteControlExtensions.cs | 15 +- .../RemoteControlServer.cs | 11 +- src/Uno.UI.RemoteControl.Host/Startup.cs | 26 +- .../AppLaunch/ApplicationLaunchMonitor.cs | 224 ++++++++++++++++-- .../Helpers/ServiceCollectionExtensions.cs | 22 ++ 9 files changed, 530 insertions(+), 70 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index 5bfab4e5433e..f2185ba60d34 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -70,6 +70,9 @@ [new ServerEndpointAttribute("localhost", helper.Port)], AssertHasEvent(events, "uno/dev-server/app-launch/launched"); AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + // Normal IDE-initiated launch: registration came before connection + AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "True"); + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); } finally @@ -135,6 +138,9 @@ [new ServerEndpointAttribute("localhost", helper.Port)], AssertHasEvent(events, "uno/dev-server/app-launch/launched"); AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + // Normal IDE-initiated launch: registration came before connection + AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "True"); + helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); } finally @@ -274,6 +280,124 @@ public async Task WhenRegisteredButNoConnection_TimeoutEventEmitted() } } + [TestMethod] + public async Task WhenConnectedBeforeRegistered_ThenAssociatedAfterRegistration() + { + // PRE-ARRANGE: Create a solution file + var solution = SolutionHelper!; + await solution.CreateSolutionFileAsync(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_connected_before_registered")); + await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); + + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + var asm = typeof(AppLaunchIntegrationTests).Assembly; + var mvid = ApplicationInfoHelper.GetMvid(asm); + var platform = ApplicationInfoHelper.GetTargetPlatform(asm); + var isDebug = Debugger.IsAttached; + + // ACT - STEP 1: Connect from application FIRST (app -> dev server) + var rcClient = RemoteControlClient.Initialize( + typeof(AppLaunchIntegrationTests), + [new ServerEndpointAttribute("localhost", helper.Port)], + _serverProcessorAssembly, + autoRegisterAppIdentity: true); + + await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10)); + + // ACT - STEP 2: Register app launch LATER via HTTP GET (simulating late IDE -> dev server) + using (var http = new HttpClient()) + { + var platformQs = platform is { Length: > 0 } ? "&platform=" + Uri.EscapeDataString(platform) : string.Empty; + var url = $"http://localhost:{helper.Port}/applaunch/{mvid}?isDebug={isDebug.ToString().ToLowerInvariant()}{platformQs}"; + var response = await http.GetAsync(url, CT); + response.EnsureSuccessStatusCode(); + } + + // ACT - STEP 3: Stop and gather telemetry events + await Task.Delay(1500, CT); + await helper.AttemptGracefulShutdownAsync(CT); + + // ASSERT + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue(); + events.Should().NotBeEmpty(); + WriteEventsList(events); + + // Desired behavior: even if connection arrived before registration, we should eventually see both events + AssertHasEvent(events, "uno/dev-server/app-launch/launched"); + AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + + // The connection should be flagged as IDE-initiated since a registration was received (even if late) + AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "True"); + } + finally + { + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + + [TestMethod] + public async Task WhenConnectedWithoutRegistration_ThenClassifiedAsNonIdeLaunch() + { + // PRE-ARRANGE: Create a solution file + var solution = SolutionHelper!; + await solution.CreateSolutionFileAsync(); + + var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_unsolicited_connection")); + await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); + + try + { + // ARRANGE + var started = await helper.StartAsync(CT); + helper.EnsureStarted(); + + // ACT - connect an app but never register a launch (unsolicited connection) + var rcClient = RemoteControlClient.Initialize( + typeof(AppLaunchIntegrationTests), + [new ServerEndpointAttribute("localhost", helper.Port)], + _serverProcessorAssembly, + autoRegisterAppIdentity: true); + + await WaitForClientConnectionAsync(rcClient, TimeSpan.FromSeconds(10)); + + // Allow some time for the server to potentially classify this case + await Task.Delay(3000, CT); + + // Stop and gather telemetry + await helper.AttemptGracefulShutdownAsync(CT); + + var events = ParseTelemetryFileIfExists(filePath); + started.Should().BeTrue(); + events.Should().NotBeEmpty(); + WriteEventsList(events); + + // Desired behavior: an unsolicited connection should still be logged as a connection, even without IDE registration + AssertHasEvent(events, "uno/dev-server/app-launch/connected"); + + // CRITICAL: The connection should be flagged as NOT IDE-initiated (manual launch, F5 in browser, etc.) + AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "False"); + } + finally + { + await helper.StopAsync(CT); + DeleteIfExists(filePath); + + TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine(helper.ConsoleOutput); + } + } + private static List<(string Prefix, JsonDocument Json)> ParseTelemetryFileIfExists(string path) => File.Exists(path) ? ParseTelemetryEvents(File.ReadAllText(path)) : []; diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs index 4e0ce4e2fc3b..902cc0471a6b 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/Given_ApplicationLaunchMonitor.cs @@ -14,7 +14,8 @@ private static ApplicationLaunchMonitor CreateMonitor( out List connectionWasTimedOut, TimeSpan? timeout = null, TimeSpan? retention = null, - TimeSpan? scavengeInterval = null) + TimeSpan? scavengeInterval = null, + Action? onPendingClassified = null) { // Use local lists inside callbacks, then assign them to out parameters var registeredList = new List(); @@ -34,6 +35,7 @@ private static ApplicationLaunchMonitor CreateMonitor( }, Retention = retention ?? TimeSpan.FromHours(1), ScavengeInterval = scavengeInterval ?? TimeSpan.FromMinutes(5), + OnPendingClassified = onPendingClassified, }; clock = new FakeTimeProvider(DateTimeOffset.UtcNow); @@ -224,16 +226,6 @@ public void WhenTimeoutExpiresWithMixedExpiredAndActive_ThenOnlyExpiredAreRemove connections.Should().HaveCount(1); } - [TestMethod] - public void WhenPlatformIsNullOrEmptyInRegisterOrReport_ThenThrowsArgumentException() - { - using var sut = CreateMonitor(out _, out _, out _, out _, out _); - var mvid = Guid.NewGuid(); - - sut.Invoking(m => m.RegisterLaunch(mvid, string.Empty, true, ide: "UnitTestIDE", plugin: "unit-plugin")).Should().Throw(); - sut.Invoking(m => m.ReportConnection(mvid, string.Empty, true)).Should().Throw(); - } - [TestMethod] public void WhenPlatformDiffersByCaseOnReportConnection_ThenItDoesNotMatch() { @@ -252,15 +244,64 @@ public void ReportConnection_ReturnsBooleanIndicatingMatch() { using var sut = CreateMonitor(out var clock, out _, out _, out var connections, out _); var mvid = Guid.NewGuid(); - // No registration yet -> false + // No registration yet -> false (goes to pending) sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); - // Register and then report -> true - sut.RegisterLaunch(mvid, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); - sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeTrue(); + // Register first, then report connection -> true (immediate match) + var mvid2 = Guid.NewGuid(); + sut.RegisterLaunch(mvid2, "Wasm", isDebug: true, ide: "UnitTestIDE", plugin: "unit-plugin"); + sut.ReportConnection(mvid2, "Wasm", isDebug: true).Should().BeTrue(); // Already consumed -> false - sut.ReportConnection(mvid, "Wasm", isDebug: true).Should().BeFalse(); + sut.ReportConnection(mvid2, "Wasm", isDebug: true).Should().BeFalse(); + } + + [TestMethod] + public void WhenConnectionArrivesBeforeAnyRegistration_ThenPendingUntilRegistered() + { + // Arrange: very small scavenge/retention so test can advance time deterministically + var pendingClassified = new List(); + using var sut = CreateMonitor( + out var clock, + out var registered, + out var timeouts, + out var connections, + out var wasTimedOut, + timeout: TimeSpan.FromSeconds(5), + retention: TimeSpan.FromMinutes(5), + scavengeInterval: TimeSpan.FromMinutes(1), + onPendingClassified: pc => pendingClassified.Add(pc)); + + var mvid = Guid.NewGuid(); + + // Act: report a connection BEFORE any launch registration (goes to pending) + var matchedNow = sut.ReportConnection(mvid, "Wasm", isDebug: false); + matchedNow.Should().BeFalse("connection should not match without prior registration"); + + // Later, register the launch - this triggers late match classification + clock.Advance(TimeSpan.FromSeconds(1)); + sut.RegisterLaunch(mvid, "Wasm", isDebug: false, ide: "UnitTestIDE", plugin: "unit-plugin"); + + // Assert: pending connection was classified as IDE-initiated via late match + pendingClassified.Should().HaveCount(1); + pendingClassified[0].WasIdeInitiated.Should().BeTrue("late registration should classify as IDE-initiated"); + pendingClassified[0].Launch.Should().NotBeNull(); + pendingClassified[0].Launch!.Ide.Should().Be("UnitTestIDE"); + } + + [TestMethod] + public void WhenUnsolicitedConnectionArrives_ThenAcceptableWithoutRegistration() + { + // This test codifies that the system must be able to classify and accept a connection + // even if no RegisterLaunch is ever received (e.g., manual app run). At the monitor level, + // we cannot fully "accept" without a registration; this asserts that no crash occurs and + // the lack of match is reported as false, leaving classification to higher layers. + using var sut = CreateMonitor(out _, out _, out _, out var connections, out _); + var mvid = Guid.NewGuid(); + + var matched = sut.ReportConnection(mvid, "Wasm", isDebug: true); + matched.Should().BeFalse(); + connections.Should().BeEmpty(); } [TestMethod] @@ -352,4 +393,70 @@ public void WhenScavengerRunsMultipleTimes_ThenOnlyOldEntriesRemovedAndNewerRema second.Should().BeFalse(); connections.Should().HaveCount(1); } + [TestMethod] + public void WhenConnectionBeforeRegistration_ThenClassifiedAfterRetry_AsNonIde() + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var classified = new List(); + using var sut = new ApplicationLaunchMonitor(clock, new ApplicationLaunchMonitor.Options + { + Timeout = TimeSpan.FromSeconds(60), + PendingRetryWindow = TimeSpan.FromMilliseconds(50), + PendingRetention = TimeSpan.FromMinutes(5), + OnPendingClassified = c => classified.Add(c), + }); + + var mvid = Guid.NewGuid(); + var platform = "Android"; + var isDebug = true; + + // Act: report connection before any registration + var matched = sut.ReportConnection(mvid, platform, isDebug); + matched.Should().BeFalse(); + // Advance past retry window to trigger classification + clock.Advance(TimeSpan.FromMilliseconds(60)); + + // Assert + classified.Should().HaveCount(1); + classified[0].WasIdeInitiated.Should().BeFalse(); + classified[0].Mvid.Should().Be(mvid); + classified[0].Platform.Should().Be(platform); + classified[0].IsDebug.Should().BeTrue(); + } + + [TestMethod] + public void WhenLateRegistration_ThenClassifiedAsIdeInitiated_WithLaunchInfo() + { + // Arrange + var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + var classified = new List(); + using var sut = new ApplicationLaunchMonitor(clock, new ApplicationLaunchMonitor.Options + { + Timeout = TimeSpan.FromSeconds(60), + PendingRetryWindow = TimeSpan.FromMilliseconds(50), + PendingRetention = TimeSpan.FromMinutes(5), + OnPendingClassified = c => classified.Add(c), + }); + + var mvid = Guid.NewGuid(); + var platform = "Wasm"; + var isDebug = false; + var ide = "Visual Studio"; + var plugin = "1.2.3"; + + // Connection arrives first + sut.ReportConnection(mvid, platform, isDebug).Should().BeFalse(); + + // Register after a small delay + clock.Advance(TimeSpan.FromMilliseconds(20)); + sut.RegisterLaunch(mvid, platform, isDebug, ide, plugin); + + // Assert: classified as IDE-initiated with launch info + classified.Should().HaveCount(1); + classified[0].WasIdeInitiated.Should().BeTrue(); + classified[0].Launch.Should().NotBeNull(); + classified[0].Launch!.Ide.Should().Be(ide); + classified[0].Launch!.Plugin.Should().Be(plugin); + } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index 10718513f950..df3dc8ea1076 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -220,5 +220,31 @@ protected static void AssertHasEvent(List<(string Prefix, JsonDocument Json)> ev .Should().BeTrue($"Should contain event '{eventName}'{(prefix != null ? $" with prefix '{prefix}'" : "")}"); } + /// + /// Assert that at least one event with the given event name exists and has the specified property with the expected value. + /// + protected static void AssertEventHasProperty( + List<(string Prefix, JsonDocument Json)> events, + string eventName, + string propertyName, + string expectedValue, + string? prefix = null) + { + var filtered = prefix == null ? events : events.Where(e => e.Prefix == prefix); + var matchingEvents = filtered + .Where(e => e.Json.RootElement.TryGetProperty("EventName", out var n) && n.GetString() == eventName) + .ToList(); + + matchingEvents.Should().NotBeEmpty($"Should contain at least one event '{eventName}'{(prefix != null ? $" with prefix '{prefix}'" : "")}"); + + var hasProperty = matchingEvents.Any(e => + e.Json.RootElement.TryGetProperty("Properties", out var props) && + props.TryGetProperty(propertyName, out var prop) && + prop.GetString() == expectedValue); + + hasProperty.Should().BeTrue( + $"Event '{eventName}' should have property '{propertyName}' with value '{expectedValue}'{(prefix != null ? $" (prefix: '{prefix}')" : "")}"); + } + protected static string GetTestTelemetryFileName(string testKey) => $"telemetry_{testKey}_{Guid.NewGuid():N}.log"; } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index 8be0f9f8fa76..30cad58967c3 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -21,10 +21,14 @@ - + + + + + @@ -35,13 +39,6 @@ - - - - - - - diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index 214fbb9d0f45..b095e6e4e242 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -37,8 +37,6 @@ public static WebApplication UseRemoteControlServer( "/applaunch/{mvid:guid}", async (HttpContext context, Guid mvid, [FromQuery] string? platform, [FromQuery] bool? isDebug, [FromQuery] string? ide, [FromQuery] string? plugin) => { - ide ??= "Unknown"; - plugin ??= "Unknown"; await HandleAppLaunchRegistrationRequest(context, mvid, platform, isDebug, ide, plugin); }) .WithName("AppLaunchRegistration"); @@ -152,21 +150,16 @@ private static async Task HandleAppLaunchRegistrationRequest( Guid mvid, string? platform, bool? isDebug, - string ide, - string plugin) + string? ide, + string? plugin) { - if (platform == string.Empty) - { - platform = null; - } - var monitor = context.RequestServices.GetRequiredService(); - monitor.RegisterLaunch(mvid, platform, isDebug ?? false, ide, plugin); + monitor.RegisterLaunch(mvid, platform, isDebug ?? false, ide ?? "Unknown", plugin ?? "Unknown"); context.Response.StatusCode = StatusCodes.Status200OK; context.Response.ContentType = "application/json"; - var response = new { mvid = mvid, targetFramework = platform ?? "unknown" }; + var response = new { mvid = mvid, targetPlatform = platform }; await context.Response.WriteAsync(JsonSerializer.Serialize(response)); } diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index 44e28678f9c7..5e9c9b5357db 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -238,7 +238,7 @@ public async Task RunAsync(WebSocket socket, CancellationToken ct) } } } - catch(WebSocketException) when (socket.State == WebSocketState.Closed) + catch (WebSocketException) when (socket.State == WebSocketState.Closed) { // Ignore "The remote party closed the WebSocket connection without completing the close handshake." // It's making noise in the logs. @@ -334,12 +334,9 @@ private async Task ProcessAppLaunchFrame(Frame frame) case AppLaunchStep.Connected: var success = monitor.ReportConnection(appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); - if (!success) + if (!success && this.Log().IsEnabled(LogLevel.Debug)) { - if (this.Log().IsEnabled(LogLevel.Error)) - { - this.Log().LogError("App Connected: MVID={Mvid} Platform={Platform} Debug={Debug} - Failed to report connected: APP LAUNCH NOT REGISTERED.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); - } + this.Log().LogDebug("App Connected: MVID={Mvid} Platform={Platform} Debug={Debug} - No immediate match, pending handled by ApplicationLaunchMonitor.", appLaunch.Mvid, appLaunch.Platform, appLaunch.IsDebug); } break; } @@ -645,6 +642,8 @@ public void Dispose() { _ct.Cancel(false); + // Nothing to flush explicitly: pending is handled internally by ApplicationLaunchMonitor + foreach (var processor in _processors) { processor.Value.Dispose(); diff --git a/src/Uno.UI.RemoteControl.Host/Startup.cs b/src/Uno.UI.RemoteControl.Host/Startup.cs index a56db46a42fd..e257a5c56341 100644 --- a/src/Uno.UI.RemoteControl.Host/Startup.cs +++ b/src/Uno.UI.RemoteControl.Host/Startup.cs @@ -54,21 +54,23 @@ public void Configure(WebApplication app) await next(); }); + app.UseRouting(); + app.UseEndpoints(endpoints => - { - try { - endpoints.MapMcp("/mcp"); - } - catch (Exception ex) - { - // MCP registration may fail if no MCP tooling is resolved - // through ServiceCollectionExtensionAttribute. This might indicate - // a missing package reference in the Uno.SDK. + try + { + endpoints.MapMcp("/mcp"); + } + catch (Exception ex) + { + // MCP registration may fail if no MCP tooling is resolved + // through ServiceCollectionExtensionAttribute. This might indicate + // a missing package reference in the Uno.SDK. - typeof(Program).Log().Log(LogLevel.Warning, ex, "Unable to find the MCP Tooling in the environment, the MCP feature is disabled."); - } - }); + typeof(Program).Log().Log(LogLevel.Warning, ex, "Unable to find the MCP Tooling in the environment, the MCP feature is disabled."); + } + }); } } } diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 0e7ec866b256..416d5f48fac9 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -14,7 +14,7 @@ namespace Uno.UI.RemoteControl.Server.AppLaunch; /// public sealed class ApplicationLaunchMonitor : IDisposable { - private const string DefaultPlatform = "Unknown"; + internal const string DefaultPlatform = "Unspecified"; /// /// Options that control the behavior of . @@ -50,6 +50,26 @@ public class Options /// public TimeSpan Retention { get; set; } = TimeSpan.FromHours(1); + /// + /// Retry window for pending connections (connections that arrived before any matching registration). + /// After this delay, a best-effort match is attempted, and if still unmatched, the connection is + /// classified as non-IDE initiated. Default: 2 seconds. + /// + public TimeSpan PendingRetryWindow { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Retention duration for pending connections allowing a late match (registration after connection). + /// Default: 1 hour. + /// + public TimeSpan PendingRetention { get; set; } = TimeSpan.FromHours(1); + + /// + /// Callback invoked when a pending connection is classified, either as non-IDE initiated (no match) + /// or as IDE-initiated (late match found). For late matches, the associated + /// is provided. + /// + public Action? OnPendingClassified { get; set; } + /// /// How often the monitor runs a scavenging pass to remove very old entries. /// Default: 1 minute. @@ -68,6 +88,17 @@ public sealed record LaunchEvent( string Plugin, DateTimeOffset RegisteredAt); + /// + /// A classification result for a pending connection. + /// + public sealed record PendingClassification( + Guid Mvid, + string Platform, + bool IsDebug, + DateTimeOffset ConnectedAt, + bool WasIdeInitiated, + LaunchEvent? Launch); + private readonly TimeProvider _timeProvider; private readonly Options _options; @@ -85,6 +116,11 @@ public sealed record LaunchEvent( // Periodic scavenger timer (removes very old entries beyond Retention) private readonly IDisposable? _scavengeTimer; + // Pending connections map (connections that arrived without a matching registration) + private readonly ConcurrentDictionary _pendingConnections = new(); + + private sealed record PendingItem(DateTimeOffset ConnectedAt, IDisposable? RetryTimer); + /// /// Creates a new instance of . /// @@ -124,7 +160,6 @@ public ApplicationLaunchMonitor(TimeProvider? timeProvider = null, Options? opti public void RegisterLaunch(Guid mvid, string? platform, bool isDebug, string ide, string plugin) { platform ??= DefaultPlatform; - ArgumentException.ThrowIfNullOrEmpty(platform); var now = _timeProvider.GetUtcNow(); var ev = new LaunchEvent(mvid, platform, isDebug, ide, plugin, now); @@ -144,6 +179,28 @@ public void RegisterLaunch(Guid mvid, string? platform, bool isDebug, string ide { // best-effort, swallow } + + // If there's already a pending connection for this key (late match), classify it immediately as IDE-initiated + if (_pendingConnections.TryRemove(key, out var pending)) + { + try { pending.RetryTimer?.Dispose(); } catch { } + // Consume the launch event without invoking OnConnected to avoid double telemetry + if (TryDequeueLaunch(key, out var matched, out var wasTimedOut) && matched is not null) + { + // Don't invoke OnConnected; instead, classify the pending connection as IDE-initiated + try + { + _options.OnPendingClassified?.Invoke(new PendingClassification( + key.Mvid, + key.Platform, + key.IsDebug, + pending.ConnectedAt, + true, + matched)); + } + catch { } + } + } } /// @@ -213,17 +270,51 @@ private void HandleTimeout(LaunchEvent launchEvent, Key key) public bool ReportConnection(Guid mvid, string? platform, bool isDebug) { platform ??= DefaultPlatform; - ArgumentException.ThrowIfNullOrEmpty(platform); var key = new Key(mvid, platform, isDebug); - // Try consume the oldest pending event if present. We prefer to dequeue an event that may have timed out - // previously (it's still in the queue). When consuming, cancel its timeout timer if any and invoke OnConnected. - if (_pending.TryGetValue(key, out var queue) && queue.TryDequeue(out var ev)) + + // Consume first the oldest pending event if present (registered launch). + // When consuming, cancel its timeout timer and invoke OnConnected with it. + if (TryDequeueLaunch(key, out var ev, out var wasTimedOut) && ev is not null) + { + try + { + _options.OnConnected?.Invoke(ev, wasTimedOut); + } + catch + { + // swallow + } + + return true; // successfully consumed a registered launch + } + + // No registered launch to consume: create a pending connection with a retry timer + SchedulePendingConnection(key); + return false; + } + + /// + /// Attempts to dequeue a registered launch for the specified key, cancelling its timeout timer and indicating + /// whether the launch had previously timed out. + /// + private bool TryDequeueLaunch(Key key, out LaunchEvent? ev, out bool wasTimedOut) + { + wasTimedOut = false; + ev = default!; + if (_pending.TryGetValue(key, out var queue) && queue.TryDequeue(out ev)) { // Cancel / dispose the timeout timer for this event if still present if (_timeoutTasks.TryRemove(ev, out var timeoutTimer)) { - try { timeoutTimer.Dispose(); } catch { } + try + { + timeoutTimer.Dispose(); + } + catch + { + // Swallow any exceptions during timer disposal + } } // If queue is now empty, remove it from dictionary @@ -232,24 +323,90 @@ public bool ReportConnection(Guid mvid, string? platform, bool isDebug) _pending.TryRemove(key, out _); } - try + if (_timedOut.TryRemove(ev, out var flag)) { - var wasTimedOut = false; - if (_timedOut.TryRemove(ev, out var flag)) - { - wasTimedOut = flag; - } + wasTimedOut = flag; + } - _options.OnConnected?.Invoke(ev, wasTimedOut); - return true; + return true; + } + + return false; + } + + private void SchedulePendingConnection(Key key) + { + var now = _timeProvider.GetUtcNow(); + // Replace any existing pending with latest timestamp + if (_pendingConnections.TryRemove(key, out var existing)) + { + try + { + existing.RetryTimer?.Dispose(); } catch { - // swallow + // Swallow any exceptions during timer disposal } } - return false; + IDisposable? timer = null; + try + { + timer = _timeProvider.CreateTimer( + static s => + { + var (self, k) = ((ApplicationLaunchMonitor, Key))s!; + self.HandlePendingRetry(k); + }, + (this, key), + _options.PendingRetryWindow, + Timeout.InfiniteTimeSpan); + } + catch { /* best-effort */ } + + _pendingConnections[key] = new PendingItem(now, timer); + } + + private void HandlePendingRetry(Key key) + { + // Try to find a launch now + if (TryDequeueLaunch(key, out var ev, out var wasTimedOut)) + { + if (_pendingConnections.TryRemove(key, out var pending)) + { + try { pending.RetryTimer?.Dispose(); } catch { } + try + { + _options.OnPendingClassified?.Invoke(new PendingClassification( + key.Mvid, + key.Platform, + key.IsDebug, + pending.ConnectedAt, + true, + ev)); + } + catch { } + } + } + else + { + // Still unmatched: classify as non-IDE initiated but keep it for a late match + if (_pendingConnections.TryGetValue(key, out var pending)) + { + try + { + _options.OnPendingClassified?.Invoke(new PendingClassification( + key.Mvid, + key.Platform, + key.IsDebug, + pending.ConnectedAt, + false, + null)); + } + catch { } + } + } } /// @@ -294,6 +451,19 @@ private void RunScavengePass() _pending.TryRemove(key, out _); } } + + // Clean pending connections older than retention + var pendingCutoff = _timeProvider.GetUtcNow() - _options.PendingRetention; + foreach (var kvp in _pendingConnections.ToArray()) + { + if (kvp.Value.ConnectedAt < pendingCutoff) + { + if (_pendingConnections.TryRemove(kvp.Key, out var pending)) + { + try { pending.RetryTimer?.Dispose(); } catch { } + } + } + } } /// @@ -314,5 +484,25 @@ public void Dispose() _timeoutTasks.Clear(); _timedOut.Clear(); _pending.Clear(); + + // Flush pending connections as non-IDE initiated + foreach (var kvp in _pendingConnections.ToArray()) + { + if (_pendingConnections.TryRemove(kvp.Key, out var pending)) + { + try { pending.RetryTimer?.Dispose(); } catch { } + try + { + _options.OnPendingClassified?.Invoke(new PendingClassification( + kvp.Key.Mvid, + kvp.Key.Platform, + kvp.Key.IsDebug, + pending.ConnectedAt, + false, + null)); + } + catch { } + } + } } } diff --git a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs index 89b36481c977..8d8e799cc323 100644 --- a/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs +++ b/src/Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs @@ -61,6 +61,7 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv ("TargetPlatform", ev.Platform), ("IsDebug", ev.IsDebug.ToString()), ("WasTimedOut", wasTimedOut.ToString()), + ("WasIdeInitiated", "True"), // Always true for matched connections ("IDE", ev.Ide), ("PluginVersion", ev.Plugin), ], @@ -80,6 +81,27 @@ public static IServiceCollection AddGlobalTelemetry(this IServiceCollection serv [("TimeoutSeconds", timeoutSeconds)]); }; + // Hook pending classification (unsolicited and late-match) + launchOptions.OnPendingClassified = pc => + { + var ide = pc.WasIdeInitiated ? (pc.Launch?.Ide ?? "Unknown") : "None"; + var plugin = pc.WasIdeInitiated ? (pc.Launch?.Plugin ?? "Unknown") : "None"; + var latencyMs = pc.WasIdeInitiated + ? (DateTimeOffset.UtcNow - (pc.Launch?.RegisteredAt ?? pc.ConnectedAt)).TotalMilliseconds + : 0.0; + + telemetry.TrackEvent("app-launch/connected", + [ + ("TargetPlatform", pc.Platform), + ("IsDebug", pc.IsDebug.ToString()), + ("WasTimedOut", "False"), + ("WasIdeInitiated", pc.WasIdeInitiated.ToString()), + ("IDE", ide), + ("PluginVersion", plugin), + ], + pc.WasIdeInitiated ? [("LatencyMs", latencyMs)] : null); + }; + return new AppLaunch.ApplicationLaunchMonitor(options: launchOptions); }); From e87d18d6a9b7c4a99fc0c13ba4a10029e818cec7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 10:46:02 -0400 Subject: [PATCH 35/49] chore(build): Update target frameworks and project references for compatibility with `$(NetCurrent)` - Replaced hardcoded `net9.0` with `$(NetCurrent)` in test project configurations. --- .../AppLaunch/RealAppLaunchIntegrationTests.cs | 7 ++++--- .../Uno.UI.RemoteControl.DevServer.Tests.csproj | 16 +++++++--------- .../Uno.UI.RemoteControl.TestProcessor.csproj | 4 +++- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 2362dcf9a364..74a6ef1aa3c4 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -10,7 +10,7 @@ namespace Uno.UI.RemoteControl.DevServer.Tests.AppLaunch; [TestClass] public class RealAppLaunchIntegrationTests : TelemetryTestBase { - private const string? _targetFramework = "net9.0"; + private const string? _targetFramework = "net10.0"; [ClassInitialize] public static void ClassInitialize(TestContext context) => GlobalClassInitialize(context); @@ -163,11 +163,12 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev /// private async Task StartSkiaDesktopAppAsync(string projectPath, int devServerPort) { + var appTfm = $"{_targetFramework}-desktop"; + // Before starting the app, make sure it will run with the freshly compiled RemoteControlClient try { var projectDir = Path.GetDirectoryName(projectPath)!; - var appTfm = $"{_targetFramework}-desktop"; var appOutputDir = Path.Combine(projectDir, "bin", "Debug", appTfm); var freshRcDll = typeof(Uno.UI.RemoteControl.RemoteControlClient).Assembly.Location; var destRcDll = Path.Combine(appOutputDir, Path.GetFileName(freshRcDll)); @@ -191,7 +192,7 @@ private async Task StartSkiaDesktopAppAsync(string projectPath, int dev var runInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"run --project \"{projectPath}\" --configuration Debug --framework net9.0-desktop --no-build", + Arguments = $"run --project \"{projectPath}\" --configuration Debug --framework {appTfm} --no-build", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index 30cad58967c3..4914eaf7896f 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -1,12 +1,10 @@ - net9.0 + $(NetCurrent) enable enable false - - net9.0 @@ -33,16 +31,16 @@ - - - - + + + + - - + + diff --git a/src/Uno.UI.RemoteControl.TestProcessor/Uno.UI.RemoteControl.TestProcessor.csproj b/src/Uno.UI.RemoteControl.TestProcessor/Uno.UI.RemoteControl.TestProcessor.csproj index 97945f33bc3e..6662dc981193 100644 --- a/src/Uno.UI.RemoteControl.TestProcessor/Uno.UI.RemoteControl.TestProcessor.csproj +++ b/src/Uno.UI.RemoteControl.TestProcessor/Uno.UI.RemoteControl.TestProcessor.csproj @@ -1,11 +1,13 @@ - $(NetPrevious) + $(NetCurrent) enable enable + + From 1c10409a6301e6c0842225cfa49a526423089c73 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 10:47:17 -0400 Subject: [PATCH 36/49] docs(telemetry): Adjust documentation about telemetry events --- src/Uno.UI.RemoteControl.Host/Telemetry.md | 7 ++-- .../Telemetry.md | 32 +++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Host/Telemetry.md b/src/Uno.UI.RemoteControl.Host/Telemetry.md index 9f08042e5174..21f337b38ff9 100644 --- a/src/Uno.UI.RemoteControl.Host/Telemetry.md +++ b/src/Uno.UI.RemoteControl.Host/Telemetry.md @@ -23,7 +23,7 @@ Event name prefix: uno/dev-server | **client-connection-opened** [[src]](RemoteControlExtensions.cs#L92) | ConnectionId | | Metadata fields are anonymized | Per-connection | | **client-connection-closed** [[src]](RemoteControlExtensions.cs#L139) | ConnectionId | ConnectionDurationSeconds | | Per-connection | | **app-launch/launched** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L48) | TargetPlatform, IsDebug, IDE, PluginVersion | | No identifiers (MVID not sent) | Global | -| **app-launch/connected** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L59) | TargetPlatform, IsDebug, IDE, PluginVersion, WasTimedOut | LatencyMs | No identifiers (MVID not sent) | Global | +| **app-launch/connected** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L59) | TargetPlatform, IsDebug, IDE, PluginVersion, WasTimedOut, WasIdeInitiated | LatencyMs | No identifiers (MVID not sent) | Global | | **app-launch/connection-timeout** [[src]](../Uno.UI.RemoteControl.Server/Helpers/ServiceCollectionExtensions.cs#L73) | TargetPlatform, IsDebug, IDE, PluginVersion | TimeoutSeconds | No identifiers (MVID not sent) | Global | ## Property Value Examples @@ -47,9 +47,10 @@ Event name prefix: uno/dev-server - **ConnectionId**: `"conn-abc123"`, `"conn-xyz789"` - **TargetPlatform**: `"Desktop1.0"`, `"Android35.0"`, `"BrowerWasm1.0"`, `"iOS18.5"`... - **IsDebug**: `"True"`, `"False"` -- **IDE**: `"vswin"`, `"rider-2025.2.0.1"`, `"vscode-1.105.0"`, `"Unknown"` -- **PluginVersion**: `"1.0.0"`, `"2.1.5"` +- **IDE**: `"vswin"`, `"rider-2025.2.0.1"`, `"vscode-1.105.0"`, `"Unknown"`, `"None"` +- **PluginVersion**: `"1.0.0"`, `"2.1.5"`, `"Unknown"`, `"None"` - **WasTimedOut**: `"True"`, `"False"` +- **WasIdeInitiated**: `"True"`, `"False"` ## Notes - ErrorMessage and StackTrace are sent as raw values and may contain sensitive information; handle with care. diff --git a/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md b/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md index 1ef0f18a1421..d675bcaae6fc 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md +++ b/src/Uno.UI.RemoteControl.Server.Processors/Telemetry.md @@ -6,17 +6,17 @@ Event name prefix: uno/dev-server/hot-reload | Event Name | Main Properties (with hotreload/ prefix) | Measurements (with hotreload/ prefix) | |-------------------------|-------------------------------------------------------|---------------------------------------| -| **notify-start** [[src]](HotReload/ServerHotReloadProcessor.cs#L158) | Event, Source, PreviousState | FileCount | -| **notify-disabled** [[src]](HotReload/ServerHotReloadProcessor.cs#L168) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | -| **notify-initializing** [[src]](HotReload/ServerHotReloadProcessor.cs#L174) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | -| **notify-ready** [[src]](HotReload/ServerHotReloadProcessor.cs#L180) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | -| **notify-processing-files** [[src]](HotReload/ServerHotReloadProcessor.cs#L186) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount | -| **notify-completed** [[src]](HotReload/ServerHotReloadProcessor.cs#L191) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | -| **notify-no-changes** [[src]](HotReload/ServerHotReloadProcessor.cs#L196) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | -| **notify-failed** [[src]](HotReload/ServerHotReloadProcessor.cs#L201) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | -| **notify-rude-edit** [[src]](HotReload/ServerHotReloadProcessor.cs#L206) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | -| **notify-complete** [[src]](HotReload/ServerHotReloadProcessor.cs#L212) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs | -| **notify-error** [[src]](HotReload/ServerHotReloadProcessor.cs#L221) | Event, Source, PreviousState, ErrorMessage, ErrorType | FileCount, DurationMs | +| **notify-start** [[src]](HotReload/ServerHotReloadProcessor.cs#L158) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-disabled** [[src]](HotReload/ServerHotReloadProcessor.cs#L168) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-initializing** [[src]](HotReload/ServerHotReloadProcessor.cs#L174) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-ready** [[src]](HotReload/ServerHotReloadProcessor.cs#L180) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-processing-files** [[src]](HotReload/ServerHotReloadProcessor.cs#L186) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-completed** [[src]](HotReload/ServerHotReloadProcessor.cs#L191) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-no-changes** [[src]](HotReload/ServerHotReloadProcessor.cs#L196) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-failed** [[src]](HotReload/ServerHotReloadProcessor.cs#L201) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-rude-edit** [[src]](HotReload/ServerHotReloadProcessor.cs#L206) | Event, Source, PreviousState | FileCount, DurationMs (optional) | +| **notify-complete** [[src]](HotReload/ServerHotReloadProcessor.cs#L212) | Event, Source, PreviousState, NewState, HasCurrentOperation | FileCount, DurationMs (optional) | +| **notify-error** [[src]](HotReload/ServerHotReloadProcessor.cs#L221) | Event, Source, PreviousState, NewState, HasCurrentOperation, ErrorMessage, ErrorType | FileCount, DurationMs (optional) | ## Property Value Examples @@ -33,10 +33,10 @@ Event name prefix: uno/dev-server/hot-reload - **Event**: The type of event that triggered the notification - **Source**: The source of the event - **PreviousState**: The state before the operation -- **NewState**: The state after the operation (when applicable) -- **HasCurrentOperation**: Indicates if a Hot Reload operation is in progress -- **FileCount**: Number of files affected by the operation -- **DurationMs**: Duration of the operation in milliseconds (if applicable) -- **ErrorMessage**/**ErrorType**: Only present on error events +- **NewState**: The state after the operation (only present in notify-complete and notify-error) +- **HasCurrentOperation**: Indicates if a Hot Reload operation is in progress (only present in notify-complete and notify-error) +- **FileCount**: Number of files affected by the operation (only present if there is a current operation) +- **DurationMs**: Duration of the operation in milliseconds (only present if the operation has completed) +- **ErrorMessage**/**ErrorType**: Only present on notify-error events All events are tracked server-side in `Notify()`. From c514182b2b3812f6bfa76cb1edccc6985949f469 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 11:01:54 -0400 Subject: [PATCH 37/49] fix(tests): Remove unnecessary null-forgiveness operators --- .../AppLaunch/AppLaunchIntegrationTests.cs | 27 +++++++------ .../RealAppLaunchIntegrationTests.cs | 40 +++++++++---------- .../Telemetry/ServerTelemetryTests.cs | 4 +- .../Telemetry/TelemetryProcessorTests.cs | 4 +- .../Telemetry/TelemetryTestBase.cs | 14 +++---- 5 files changed, 45 insertions(+), 44 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index f2185ba60d34..c0b3aa30da2d 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -23,7 +23,8 @@ public class AppLaunchIntegrationTests : TelemetryTestBase public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() { // PRE-ARRANGE: Create a solution file - var solution = SolutionHelper!; + var solution = SolutionHelper; + Assert.IsNotNull(solution); await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success")); @@ -80,7 +81,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } @@ -89,7 +90,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], public async Task WhenRegisteredByAssemblyPathAndRuntimeConnects_SuccessEventEmitted() { // PRE-ARRANGE: Create a solution file - var solution = SolutionHelper!; + var solution = SolutionHelper; await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success_by_path")); @@ -114,7 +115,7 @@ public async Task WhenRegisteredByAssemblyPathAndRuntimeConnects_SuccessEventEmi var url = $"http://localhost:{helper.Port}/applaunch/asm/{encodedPath}?IsDebug={isDebug.ToString().ToLowerInvariant()}"; var response = await http.GetAsync(url, CT); response.EnsureSuccessStatusCode(); - TestContext!.WriteLine("Http Response: " + await response.Content.ReadAsStringAsync()); + TestContext.WriteLine("Http Response: " + await response.Content.ReadAsStringAsync()); } // ACT - STEP 2: Connect from application (simulating app -> dev server) @@ -148,7 +149,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } @@ -158,7 +159,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted_UsingIdeChannel() { // PRE-ARRANGE: Create a solution file - var solution = SolutionHelper!; + var solution = SolutionHelper; await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_success_idechannel")); @@ -211,7 +212,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } @@ -220,7 +221,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], public async Task WhenRegisteredButNoConnection_TimeoutEventEmitted() { // PRE-ARRANGE: Create a solution file - var solution = SolutionHelper!; + var solution = SolutionHelper; await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_timeout")); @@ -275,7 +276,7 @@ public async Task WhenRegisteredButNoConnection_TimeoutEventEmitted() await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } @@ -284,7 +285,7 @@ public async Task WhenRegisteredButNoConnection_TimeoutEventEmitted() public async Task WhenConnectedBeforeRegistered_ThenAssociatedAfterRegistration() { // PRE-ARRANGE: Create a solution file - var solution = SolutionHelper!; + var solution = SolutionHelper; await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_connected_before_registered")); @@ -341,7 +342,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } @@ -350,7 +351,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], public async Task WhenConnectedWithoutRegistration_ThenClassifiedAsNonIdeLaunch() { // PRE-ARRANGE: Create a solution file - var solution = SolutionHelper!; + var solution = SolutionHelper; await solution.CreateSolutionFileAsync(); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_unsolicited_connection")); @@ -393,7 +394,7 @@ [new ServerEndpointAttribute("localhost", helper.Port)], await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs index 74a6ef1aa3c4..f0001511d30f 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/RealAppLaunchIntegrationTests.cs @@ -19,7 +19,7 @@ public class RealAppLaunchIntegrationTests : TelemetryTestBase public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished() { // PRE-ARRANGE: Create a real Uno solution file (will contain desktop project) - var solution = SolutionHelper!; + var solution = SolutionHelper; await solution.CreateSolutionFileAsync(platforms: "desktop", targetFramework: _targetFramework); var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_app_success")); @@ -81,7 +81,7 @@ public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished( } catch (Exception ex) { - TestContext!.WriteLine($"Error stopping Skia process: {ex.Message}"); + TestContext.WriteLine($"Error stopping Skia process: {ex.Message}"); } appProcess.Dispose(); } @@ -89,7 +89,7 @@ public async Task WhenRealAppBuiltAndRunWithDevServer_RealConnectionEstablished( await helper.StopAsync(CT); DeleteIfExists(filePath); - TestContext!.WriteLine("Dev Server Output:"); + TestContext.WriteLine("Dev Server Output:"); TestContext.WriteLine(helper.ConsoleOutput); } } @@ -101,14 +101,14 @@ private async Task RegisterAppLaunchAsync(string projectPath, int httpPort) var tfm = $"{_targetFramework}-desktop"; var assemblyPath = Path.Combine(projectDir, "bin", "Debug", tfm, assemblyName + ".dll"); - TestContext!.WriteLine($"Reading assembly info from: {assemblyPath}"); + TestContext.WriteLine($"Reading assembly info from: {assemblyPath}"); var (mvid, platformName) = AssemblyInfoReader.Read(assemblyPath); var platform = platformName ?? "Desktop"; using (var http = new HttpClient()) { var url = $"http://localhost:{httpPort}/applaunch/{mvid}?platform={Uri.EscapeDataString(platform)}&isDebug=false"; - TestContext!.WriteLine($"Registering app launch: {url}"); + TestContext.WriteLine($"Registering app launch: {url}"); var response = await http.GetAsync(url, CT); response.EnsureSuccessStatusCode(); } @@ -130,7 +130,7 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev throw new InvalidOperationException("Could not find a project in the generated solution"); } - TestContext!.WriteLine($"Building desktop project: {appProject}"); + TestContext.WriteLine($"Building desktop project: {appProject}"); // Build the project with devserver configuration so the generators create the right ServerEndpointAttribute // Using MSBuild properties directly to override any .csproj.user or Directory.Build.props values @@ -148,7 +148,7 @@ private async Task BuildAppProjectAsync(SolutionHelper solution, int dev var (exitCode, output) = await ProcessUtil.RunProcessAsync(buildInfo); - TestContext!.WriteLine($"Build output: {output}"); + TestContext.WriteLine($"Build output: {output}"); if (exitCode != 0) { @@ -182,11 +182,11 @@ private async Task StartSkiaDesktopAppAsync(string projectPath, int dev { File.Copy(freshRcPdb, destRcPdb, overwrite: true); } - TestContext!.WriteLine($"Overwrote RemoteControlClient assembly: {destRcDll}"); + TestContext.WriteLine($"Overwrote RemoteControlClient assembly: {destRcDll}"); } catch (Exception copyEx) { - TestContext!.WriteLine($"Warning: Failed to overwrite RemoteControlClient assembly: {copyEx}"); + TestContext.WriteLine($"Warning: Failed to overwrite RemoteControlClient assembly: {copyEx}"); } var runInfo = new ProcessStartInfo @@ -212,7 +212,7 @@ private async Task StartSkiaDesktopAppAsync(string projectPath, int dev if (e.Data != null) { outputBuilder.AppendLine(e.Data); - TestContext!.WriteLine($"[APP-OUT] {e.Data}"); + TestContext.WriteLine($"[APP-OUT] {e.Data}"); } }; @@ -221,7 +221,7 @@ private async Task StartSkiaDesktopAppAsync(string projectPath, int dev if (e.Data != null) { outputBuilder.AppendLine(e.Data); - TestContext!.WriteLine($"[APP-ERR] {e.Data}"); + TestContext.WriteLine($"[APP-ERR] {e.Data}"); } }; @@ -229,7 +229,7 @@ private async Task StartSkiaDesktopAppAsync(string projectPath, int dev process.BeginOutputReadLine(); process.BeginErrorReadLine(); - TestContext!.WriteLine($"Started Skia desktop app process with PID: {process.Id}"); + TestContext.WriteLine($"Started Skia desktop app process with PID: {process.Id}"); // Wait a moment for the app to start await Task.Delay(2000, CT); @@ -250,8 +250,8 @@ private async Task WaitForAppToConnectoToDevServerAsync(DevServerTestHelper help { var startTime = Stopwatch.GetTimestamp(); - TestContext!.WriteLine("Waiting for real Skia app to connect to devserver..."); - TestContext!.WriteLine("The app should connect automatically via the generated ServerEndpointAttribute during build."); + TestContext.WriteLine("Waiting for real Skia app to connect to devserver..."); + TestContext.WriteLine("The app should connect automatically via the generated ServerEndpointAttribute during build."); // For this integration test, we'll wait a reasonable time for the app to start // and assume success if no catastrophic errors occur. The goal is to verify @@ -269,13 +269,13 @@ private async Task WaitForAppToConnectoToDevServerAsync(DevServerTestHelper help // Check if we can see the app connection in devserver output var devServerOutput = helper.ConsoleOutput; - TestContext!.WriteLine($"[{iterations}/{maxIterations}] Checking devserver output... ({devServerOutput.Length} chars)"); + TestContext.WriteLine($"[{iterations}/{maxIterations}] Checking devserver output... ({devServerOutput.Length} chars)"); // Look for connection success indicators if (devServerOutput.Contains("App Connected:")) { - TestContext!.WriteLine("✅ SUCCESS: Skia app successfully connected to devserver!"); - TestContext!.WriteLine("Connection detected in devserver logs - integration test objective achieved."); + TestContext.WriteLine("✅ SUCCESS: Skia app successfully connected to devserver!"); + TestContext.WriteLine("Connection detected in devserver logs - integration test objective achieved."); connectionDetected = true; break; } @@ -283,9 +283,9 @@ private async Task WaitForAppToConnectoToDevServerAsync(DevServerTestHelper help if (!connectionDetected) { - TestContext!.WriteLine("⚠️ Connection not detected in logs, but test may still be successful."); - TestContext!.WriteLine("The real Skia app was built and launched successfully with devserver configuration."); - TestContext!.WriteLine($"DevServer output: {helper.ConsoleOutput}"); + TestContext.WriteLine("⚠️ Connection not detected in logs, but test may still be successful."); + TestContext.WriteLine("The real Skia app was built and launched successfully with devserver configuration."); + TestContext.WriteLine($"DevServer output: {helper.ConsoleOutput}"); } } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs index 47dc5f832861..0f988ef4ae3e 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/ServerTelemetryTests.cs @@ -11,7 +11,7 @@ public class TelemetryServerTests : TelemetryTestBase [TestMethod] public async Task Telemetry_Server_LogsConnectionEvents() { - var solution = SolutionHelper!; + var solution = SolutionHelper; // Arrange var fileName = GetTestTelemetryFileName("serverconn"); @@ -58,7 +58,7 @@ public async Task Telemetry_Server_LogsConnectionEvents() [TestMethod] public async Task Telemetry_FileTelemetry_AppliesEventsPrefix() { - var solution = SolutionHelper!; + var solution = SolutionHelper; // Arrange var fileName = GetTestTelemetryFileName("eventsprefix"); diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs index 4027d7ac5ccf..3f381e4e2014 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryProcessorTests.cs @@ -16,7 +16,7 @@ public class TelemetryProcessorTests : TelemetryTestBase [TestMethod] public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() { - var solution = SolutionHelper!; + var solution = SolutionHelper; var appInstanceId = Guid.NewGuid().ToString("N"); // Arrange - Create a temporary file for telemetry output @@ -132,7 +132,7 @@ public async Task Telemetry_ProcessorDiscovery_LogsDiscoveryEvents() [TestMethod] public async Task Telemetry_ServerHotReloadProcessor_ResolvesCorrectly() { - var solution = SolutionHelper!; + var solution = SolutionHelper; var appInstanceId = Guid.NewGuid().ToString("N"); // Arrange - Create a temporary file for telemetry output diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index df3dc8ea1076..1524bc1da1b6 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -11,11 +11,11 @@ public abstract class TelemetryTestBase { protected static ILogger Logger { get; private set; } = null!; - public TestContext? TestContext { get; set; } + public TestContext TestContext { get; set; } = null!; private CancellationToken GetTimeoutToken() { - var baseToken = TestContext?.CancellationTokenSource.Token ?? CancellationToken.None; + var baseToken = TestContext.CancellationTokenSource.Token; if (!Debugger.IsAttached) { var cts = CancellationTokenSource.CreateLinkedTokenSource(baseToken); @@ -33,7 +33,7 @@ private CancellationToken GetTimeoutToken() protected CancellationToken CT => GetTimeoutToken(); - protected SolutionHelper? SolutionHelper { get; private set; } + protected SolutionHelper SolutionHelper { get; private set; } = null!; [TestInitialize] public void TestInitialize() @@ -45,8 +45,8 @@ public void TestInitialize() [TestCleanup] public void TestCleanup() { - SolutionHelper?.Dispose(); - SolutionHelper = null; + SolutionHelper.Dispose(); + SolutionHelper = null!; } private static void InitializeLogger() where T : class @@ -199,13 +199,13 @@ protected static void AssertHasPrefix(List<(string Prefix, JsonDocument Json)> e protected void WriteEventsList(List<(string Prefix, JsonDocument Json)> events) { - TestContext!.WriteLine($"Found {events.Count} telemetry events:"); + TestContext.WriteLine($"Found {events.Count} telemetry events:"); var index = 1; foreach (var (prefix, json) in events) { if (json.RootElement.TryGetProperty("EventName", out var eventName)) { - TestContext!.WriteLine($"[{index++}] Prefix: {prefix}, EventName: {eventName.GetString()}"); + TestContext.WriteLine($"[{index++}] Prefix: {prefix}, EventName: {eventName.GetString()}"); } } } From e93cef3ffc602ca01244726466eb7a750059e48d Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 11:10:59 -0400 Subject: [PATCH 38/49] chore(remote-control): Correct typos --- .../RemoteControlServer.cs | 5 ++++- .../RemoteControlClient.cs | 18 +++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs index 5e9c9b5357db..49d2359e8b1a 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlServer.cs @@ -255,7 +255,10 @@ public async Task RunAsync(WebSocket socket, CancellationToken ct) private void ProcessIdeMessage(object? sender, IdeMessage message) { - Console.WriteLine($"Received message from IDE: {message.GetType().Name}"); + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().LogDebug("Received message from IDE: {MessageType}", message.GetType().Name); + } if (message is AppLaunchRegisterIdeMessage appLaunchRegisterIdeMessage) { if (this.Log().IsEnabled(LogLevel.Debug)) diff --git a/src/Uno.UI.RemoteControl/RemoteControlClient.cs b/src/Uno.UI.RemoteControl/RemoteControlClient.cs index 49e6d516ddc1..39d5d61affc9 100644 --- a/src/Uno.UI.RemoteControl/RemoteControlClient.cs +++ b/src/Uno.UI.RemoteControl/RemoteControlClient.cs @@ -31,7 +31,7 @@ namespace Uno.UI.RemoteControl; public partial class RemoteControlClient : IRemoteControlClient, IAsyncDisposable { - private readonly string? _additionnalServerProcessorsDiscoveryPath; + private readonly string? _additionalServerProcessorsDiscoveryPath; private readonly bool _autoRegisterAppIdentity; public delegate void RemoteControlFrameReceivedEventHandler(object sender, ReceivedFrameEventArgs args); @@ -129,7 +129,7 @@ internal static RemoteControlClient Initialize(Type appType, ServerEndpointAttri /// /// The type of the application entry point. /// Optional list of fallback endpoints to try when connecting to the dev-server. - /// An optional absolute or relative path used to discover additional server processors. + /// An optional absolute or relative path used to discover additional server processors. /// Whether to automatically register the app identity (mvid - platform...) with the dev-server. /// The initialized RemoteControlClient singleton instance. /// @@ -140,9 +140,9 @@ internal static RemoteControlClient Initialize(Type appType, ServerEndpointAttri internal static RemoteControlClient Initialize( Type appType, ServerEndpointAttribute[]? endpoints, - string? additionnalServerProcessorsDiscoveryPath, + string? additionalServerProcessorsDiscoveryPath, bool autoRegisterAppIdentity = true) - => Instance = new RemoteControlClient(appType, endpoints, additionnalServerProcessorsDiscoveryPath, autoRegisterAppIdentity); + => Instance = new RemoteControlClient(appType, endpoints, additionalServerProcessorsDiscoveryPath, autoRegisterAppIdentity); public event RemoteControlFrameReceivedEventHandler? FrameReceived; public event RemoteControlClientEventEventHandler? ClientEvent; @@ -213,11 +213,11 @@ public async ValueTask DisposeAsync() private RemoteControlClient(Type appType, ServerEndpointAttribute[]? endpoints = null, - string? additionnalServerProcessorsDiscoveryPath = null, + string? additionalServerProcessorsDiscoveryPath = null, bool autoRegisterAppIdentity = true) { AppType = appType; - _additionnalServerProcessorsDiscoveryPath = additionnalServerProcessorsDiscoveryPath; + _additionalServerProcessorsDiscoveryPath = additionalServerProcessorsDiscoveryPath; _autoRegisterAppIdentity = autoRegisterAppIdentity; _status = new StatusSink(this); @@ -843,10 +843,10 @@ private void StartKeepAliveTimer() private async Task InitializeServerProcessors() { var anyDiscoveryRequested = false; - if (_additionnalServerProcessorsDiscoveryPath is not null) + if (_additionalServerProcessorsDiscoveryPath is not null) { anyDiscoveryRequested = true; - await SendMessage(new ProcessorsDiscovery(_additionnalServerProcessorsDiscoveryPath)); + await SendMessage(new ProcessorsDiscovery(_additionalServerProcessorsDiscoveryPath)); } if (AppType.Assembly.GetCustomAttributes(typeof(ServerProcessorsConfigurationAttribute), false) is ServerProcessorsConfigurationAttribute[] { Length: > 0 } configs) @@ -950,7 +950,7 @@ public async ValueTask DisposeAsync() // Stop the keep alive timer Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); - // Remove the instance if it's the current one' (should not happen in regular usage) + // Remove the instance if it's the current one (should not happen in regular usage) if (ReferenceEquals(Instance, this)) { Instance = null; From a7f26a677c242c4824e1fe95f6ca33a9022014d0 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 11:15:50 -0400 Subject: [PATCH 39/49] feat(app-launch): Include IDE version in app launch state consumer initialization --- .../AppLaunch/VsAppLaunchStateConsumer.cs | 18 ++++++++------- src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 22 ++++++++++++++++++- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs index 351a5ece8ffd..3925b3bd2415 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateConsumer.cs @@ -18,26 +18,30 @@ internal sealed class VsAppLaunchStateConsumer : IDisposable private readonly VsAppLaunchStateService _stateService; private readonly Func _ideChannelAccessor; private readonly string _packageVersion; + private readonly string _ideVersion; private VsAppLaunchStateConsumer( AsyncPackage package, VsAppLaunchStateService stateService, Func ideChannelAccessor, - string packageVersion) + string packageVersion, + string ideVersion) { _package = package; _stateService = stateService; _ideChannelAccessor = ideChannelAccessor; _packageVersion = packageVersion; + _ideVersion = ideVersion; } public static async Task CreateAsync( AsyncPackage package, VsAppLaunchStateService stateService, Func ideChannelAccessor, - string packageVersion) + string packageVersion, + string ideVersion) { - var c = new VsAppLaunchStateConsumer(package, stateService, ideChannelAccessor, packageVersion); + var c = new VsAppLaunchStateConsumer(package, stateService, ideChannelAccessor, packageVersion, ideVersion); await c.InitializeAsync(); return c; } @@ -49,7 +53,8 @@ public static Task CreateAsync( Func ideChannelAccessor) { var packageVersion = typeof(VsAppLaunchStateConsumer).Assembly.GetName().Version?.ToString() ?? string.Empty; - return CreateAsync(package, stateService, ideChannelAccessor, packageVersion); + var ideVersion = "vswin"; + return CreateAsync(package, stateService, ideChannelAccessor, packageVersion, ideVersion); } private Task InitializeAsync() @@ -89,10 +94,7 @@ private async Task ExtractAndSendAppLaunchInfoAsync(AppLaunchDetails details) var ideChannel = _ideChannelAccessor(); if (ideChannel != null && details.IsDebug is { } isDebug) { - // Provide IDE and plugin metadata. For Visual Studio host, report product name and unknown plugin version when not available. - var ideName = "vswin-"; - var pluginVersion = _packageVersion; - var message = new AppLaunchRegisterIdeMessage(mvid, platform, isDebug, ideName, pluginVersion); + var message = new AppLaunchRegisterIdeMessage(mvid, platform, isDebug, _ideVersion, _packageVersion); await ideChannel.SendToDevServerAsync(message, CancellationToken.None); } } diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index 1d02e16578de..cf6a97536c64 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -216,9 +216,10 @@ private async Task InitializeAppLaunchTrackingAsync(AsyncPackage asyncPackage) }; var packageVersion = GetAssemblyVersion(); + var ideVersion = GetIdeVersion(); _appLaunchIdeBridge = await VsAppLaunchIdeBridge.CreateAsync(asyncPackage, _dte2, stateService); - _appLaunchStateConsumer = await VsAppLaunchStateConsumer.CreateAsync(asyncPackage, stateService, () => _ideChannelClient, packageVersion); + _appLaunchStateConsumer = await VsAppLaunchStateConsumer.CreateAsync(asyncPackage, stateService, () => _ideChannelClient, packageVersion, ideVersion); } private Task> OnProvideGlobalPropertiesAsync() @@ -313,6 +314,25 @@ private string GetAssemblyVersion() } } + private string GetIdeVersion() + { + try + { + // DTE2.Version gives the Visual Studio version (e.g., "17.0" for VS 2022) + var vsVersion = _dte2?.Version; + if (!string.IsNullOrEmpty(vsVersion)) + { + return $"vswin-{vsVersion}"; + } + } + catch + { + // Swallow any exceptions when retrieving VS version + } + + return "vswin"; + } + private async Task UpdateProjectsAsync() { try From 7c4c9634f9f723f7b8ebff353406fba7e2872817 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 11:41:56 -0400 Subject: [PATCH 40/49] chore(code-style): Apply consistent formatting across tests and helpers --- .../AppLaunch/AppLaunchIntegrationTests.cs | 55 ++++++++++---- .../IDEChannel/IdeChannelServer.cs | 12 ++- .../AppLaunch/ApplicationLaunchMonitor.cs | 4 +- .../AppLaunch/VsAppLaunchStateService.md | 2 +- .../Helpers/AssemblyInfoReader.cs | 76 +++++++++---------- .../IDEChannel/IDEChannelClient.cs | 12 ++- .../RemoteControlClient.cs | 7 +- 7 files changed, 107 insertions(+), 61 deletions(-) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs index c0b3aa30da2d..c958bb98784d 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/AppLaunch/AppLaunchIntegrationTests.cs @@ -17,7 +17,8 @@ public class AppLaunchIntegrationTests : TelemetryTestBase dllFileName: "Uno.UI.RemoteControl.Server.Processors.dll"); [ClassInitialize] - public static void ClassInitialize(TestContext context) => GlobalClassInitialize(context); + public static void ClassInitialize(TestContext context) => + GlobalClassInitialize(context); [TestMethod] public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() @@ -38,7 +39,9 @@ public async Task WhenRegisteredAndRuntimeConnects_SuccessEventEmitted() var asm = typeof(AppLaunchIntegrationTests).Assembly; var mvid = ApplicationInfoHelper.GetMvid(asm); - var platform = ApplicationInfoHelper.GetTargetPlatform(asm) is { } p ? "&platform=" + Uri.EscapeDataString(p) : null; + var platform = ApplicationInfoHelper.GetTargetPlatform(asm) is { } p + ? "&platform=" + Uri.EscapeDataString(p) + : null; var isDebug = Debugger.IsAttached; // ACT - STEP 1: Register app launch via HTTP GET (simulating IDE -> dev server) @@ -72,7 +75,11 @@ [new ServerEndpointAttribute("localhost", helper.Port)], AssertHasEvent(events, "uno/dev-server/app-launch/connected"); // Normal IDE-initiated launch: registration came before connection - AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "True"); + AssertEventHasProperty( + events, + "uno/dev-server/app-launch/connected", + "WasIdeInitiated", + "True"); helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); } @@ -140,8 +147,11 @@ [new ServerEndpointAttribute("localhost", helper.Port)], AssertHasEvent(events, "uno/dev-server/app-launch/connected"); // Normal IDE-initiated launch: registration came before connection - AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "True"); - + AssertEventHasProperty( + events, + "uno/dev-server/app-launch/connected", + "WasIdeInitiated", + "True"); helper.ConsoleOutput.Length.Should().BeGreaterThan(0, "Dev server should produce some output"); } finally @@ -243,7 +253,9 @@ public async Task WhenRegisteredButNoConnection_TimeoutEventEmitted() var asm = typeof(AppLaunchIntegrationTests).Assembly; var mvid = ApplicationInfoHelper.GetMvid(asm); - var platform = ApplicationInfoHelper.GetTargetPlatform(asm) is { } p ? "&platform=" + Uri.EscapeDataString(p) : null; + var platform = ApplicationInfoHelper.GetTargetPlatform(asm) is { } p + ? "&platform=" + Uri.EscapeDataString(p) + : null; var isDebug = Debugger.IsAttached; // ACT - STEP 1: Register app launch via HTTP GET (simulating IDE -> dev server) @@ -288,8 +300,10 @@ public async Task WhenConnectedBeforeRegistered_ThenAssociatedAfterRegistration( var solution = SolutionHelper; await solution.CreateSolutionFileAsync(); - var filePath = Path.Combine(Path.GetTempPath(), GetTestTelemetryFileName("applaunch_connected_before_registered")); - await using var helper = CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); + var filePath = Path.Combine(Path.GetTempPath(), + GetTestTelemetryFileName("applaunch_connected_before_registered")); + await using var helper = + CreateTelemetryHelperWithExactPath(filePath, solutionPath: solution.SolutionFile, enableIdeChannel: false); try { @@ -314,8 +328,11 @@ [new ServerEndpointAttribute("localhost", helper.Port)], // ACT - STEP 2: Register app launch LATER via HTTP GET (simulating late IDE -> dev server) using (var http = new HttpClient()) { - var platformQs = platform is { Length: > 0 } ? "&platform=" + Uri.EscapeDataString(platform) : string.Empty; - var url = $"http://localhost:{helper.Port}/applaunch/{mvid}?isDebug={isDebug.ToString().ToLowerInvariant()}{platformQs}"; + var platformQs = platform is { Length: > 0 } + ? "&platform=" + Uri.EscapeDataString(platform) + : string.Empty; + var url = + $"http://localhost:{helper.Port}/applaunch/{mvid}?isDebug={isDebug.ToString().ToLowerInvariant()}{platformQs}"; var response = await http.GetAsync(url, CT); response.EnsureSuccessStatusCode(); } @@ -335,7 +352,11 @@ [new ServerEndpointAttribute("localhost", helper.Port)], AssertHasEvent(events, "uno/dev-server/app-launch/connected"); // The connection should be flagged as IDE-initiated since a registration was received (even if late) - AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "True"); + AssertEventHasProperty( + events, + "uno/dev-server/app-launch/connected", + "WasIdeInitiated", + "True"); } finally { @@ -387,7 +408,11 @@ [new ServerEndpointAttribute("localhost", helper.Port)], AssertHasEvent(events, "uno/dev-server/app-launch/connected"); // CRITICAL: The connection should be flagged as NOT IDE-initiated (manual launch, F5 in browser, etc.) - AssertEventHasProperty(events, "uno/dev-server/app-launch/connected", "WasIdeInitiated", "False"); + AssertEventHasProperty( + events, + "uno/dev-server/app-launch/connected", + "WasIdeInitiated", + "False"); } finally { @@ -404,6 +429,10 @@ [new ServerEndpointAttribute("localhost", helper.Port)], private static void DeleteIfExists(string path) { - if (File.Exists(path)) { try { File.Delete(path); } catch { } } + if (File.Exists(path)) + { + try { File.Delete(path); } + catch { } + } } } diff --git a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs index 8fa840dbaa09..2318ff433acf 100644 --- a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs +++ b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs @@ -135,18 +135,24 @@ private async Task InitializeServer(Guid channelId) private void ScheduleKeepAlive() { + // Capture and dispose the current timer to avoid race conditions + var oldTimer = Interlocked.Exchange(ref _keepAliveTimer, null); + oldTimer?.Dispose(); + while (_pipeServer?.IsConnected ?? false) { _keepAliveTimer?.Dispose(); } - _keepAliveTimer = new Timer(_ => { - _keepAliveTimer!.Dispose(); - _keepAliveTimer = null; + // Capture the timer instance to safely dispose it after scheduling the next one + var currentTimer = _keepAliveTimer; SendKeepAlive(); ScheduleKeepAlive(); + + // Dispose the captured timer after the new one is scheduled + currentTimer?.Dispose(); }, null, KeepAliveDelay, Timeout.Infinite); } diff --git a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs index 416d5f48fac9..c7fbac793e29 100644 --- a/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs +++ b/src/Uno.UI.RemoteControl.Server/AppLaunch/ApplicationLaunchMonitor.cs @@ -46,9 +46,9 @@ public class Options /// /// How long to retain launch entries before scavenging them from internal storage. /// This is independent from which only triggers the OnTimeout callback. - /// Default: 24 hours. + /// Default: 0.5 hour. /// - public TimeSpan Retention { get; set; } = TimeSpan.FromHours(1); + public TimeSpan Retention { get; set; } = TimeSpan.FromHours(0.5); /// /// Retry window for pending connections (connections that arrived before any matching registration). diff --git a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md index a121fc79867e..d725efdfe4e6 100644 --- a/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md +++ b/src/Uno.UI.RemoteControl.VS/AppLaunch/VsAppLaunchStateService.md @@ -16,7 +16,7 @@ A small, deterministic state machine that decides when a VS "Play" (Debug/Run) s - `event StateChanged(object sender, StateChangedEventArgs e)`: raised on every state transition with `TimestampUtc`, `Previous`, `Current`, `StateDetails`. The convenience property `e.Succeeded` is true only when `Current == BuildSucceeded`. ## Options -- `Options.BuildWaitWindow` (default 5 seconds): time to wait after `Start` for a build to begin. +- `Options.BuildWaitWindow` (default 8 seconds): time to wait after `Start` for a build to begin. ## States - `Idle` diff --git a/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs b/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs index 1d36e906438a..7b76324afcab 100644 --- a/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs +++ b/src/Uno.UI.RemoteControl.VS/Helpers/AssemblyInfoReader.cs @@ -2,53 +2,51 @@ using System.IO; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; -// Temporarily suppress formatting rule until code is aligned with project style -#pragma warning disable IDE0055 namespace Uno.UI.RemoteControl.VS.Helpers; internal static class AssemblyInfoReader { - /// - /// Reads MVID and TargetPlatformAttribute.PlatformName (if present) from an assembly file without loading it. - /// - public static (Guid Mvid, string? PlatformName) Read(string assemblyPath) - { - using var fs = File.OpenRead(assemblyPath); - using var pe = new PEReader(fs, PEStreamOptions.LeaveOpen); - var md = pe.GetMetadataReader(); + /// + /// Reads MVID and TargetPlatformAttribute.PlatformName (if present) from an assembly file without loading it. + /// + public static (Guid Mvid, string? PlatformName) Read(string assemblyPath) + { + using var fs = File.OpenRead(assemblyPath); + using var pe = new PEReader(fs, PEStreamOptions.LeaveOpen); + var md = pe.GetMetadataReader(); - // MVID - var mvid = md.GetGuid(md.GetModuleDefinition().Mvid); + // MVID + var mvid = md.GetGuid(md.GetModuleDefinition().Mvid); - string? platformName = null; - // [assembly: TargetPlatformAttribute("Desktop1.0")] - // Namespace: System.Runtime.Versioning - foreach (var caHandle in md.GetAssemblyDefinition().GetCustomAttributes()) - { - var ca = md.GetCustomAttribute(caHandle); - if (ca.Constructor.Kind != HandleKind.MemberReference) - { - continue; - } + string? platformName = null; + // [assembly: TargetPlatformAttribute("Desktop1.0")] + // Namespace: System.Runtime.Versioning + foreach (var caHandle in md.GetAssemblyDefinition().GetCustomAttributes()) + { + var ca = md.GetCustomAttribute(caHandle); + if (ca.Constructor.Kind != HandleKind.MemberReference) + { + continue; + } - var mr = md.GetMemberReference((MemberReferenceHandle)ca.Constructor); - var ct = md.GetTypeReference((TypeReferenceHandle)mr.Parent); - var typeName = md.GetString(ct.Name); - var typeNs = md.GetString(ct.Namespace); + var mr = md.GetMemberReference((MemberReferenceHandle)ca.Constructor); + var ct = md.GetTypeReference((TypeReferenceHandle)mr.Parent); + var typeName = md.GetString(ct.Name); + var typeNs = md.GetString(ct.Namespace); - if (typeName == "TargetPlatformAttribute" && typeNs == "System.Runtime.Versioning") - { - var valReader = md.GetBlobReader(ca.Value); - // Blob layout: prolog (0x0001), then fixed args - if (valReader.ReadUInt16() == 1) // prolog - { - platformName = valReader.ReadSerializedString(); - } - break; - } - } + if (typeName == "TargetPlatformAttribute" && typeNs == "System.Runtime.Versioning") + { + var valReader = md.GetBlobReader(ca.Value); + // Blob layout: prolog (0x0001), then fixed args + if (valReader.ReadUInt16() == 1) // prolog + { + platformName = valReader.ReadSerializedString(); + } + break; + } + } - return (mvid, platformName); - } + return (mvid, platformName); + } } diff --git a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs index b665db63152b..49df9232f25f 100644 --- a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs +++ b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs @@ -85,14 +85,22 @@ private void ScheduleKeepAlive(CancellationToken ct) { Connected?.Invoke(this, EventArgs.Empty); - _keepAliveTimer?.Dispose(); + // Capture and dispose the current timer to avoid race conditions + var oldTimer = Interlocked.Exchange(ref _keepAliveTimer, null); + oldTimer?.Dispose(); + _keepAliveTimer = new Timer(_ => { if (ct is { IsCancellationRequested: false } && _devServer is not null) { - _keepAliveTimer!.Dispose(); + // Capture the timer instance to safely dispose it after scheduling the next one + var currentTimer = _keepAliveTimer; + var t = _devServer.SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), ct); ScheduleKeepAlive(_IDEChannelCancellation!.Token); + + // Dispose the captured timer after the new one is scheduled + currentTimer?.Dispose(); } }, null, KeepAliveDelay, Timeout.Infinite); } diff --git a/src/Uno.UI.RemoteControl/RemoteControlClient.cs b/src/Uno.UI.RemoteControl/RemoteControlClient.cs index 39d5d61affc9..7e9001ea4ca2 100644 --- a/src/Uno.UI.RemoteControl/RemoteControlClient.cs +++ b/src/Uno.UI.RemoteControl/RemoteControlClient.cs @@ -35,7 +35,9 @@ public partial class RemoteControlClient : IRemoteControlClient, IAsyncDisposabl private readonly bool _autoRegisterAppIdentity; public delegate void RemoteControlFrameReceivedEventHandler(object sender, ReceivedFrameEventArgs args); + public delegate void RemoteControlClientEventEventHandler(object sender, ClientEventEventArgs args); + public delegate void SendMessageFailedEventHandler(object sender, SendMessageFailedEventArgs args); public static RemoteControlClient? Instance @@ -158,6 +160,7 @@ internal static RemoteControlClient Initialize( /// /// This applies only if a connection has been established once and has been lost by then. public TimeSpan ConnectionRetryInterval { get; } = TimeSpan.FromMilliseconds(_connectionRetryInterval); + private const int _connectionRetryInterval = 5_000; private readonly StatusSink _status; @@ -171,7 +174,8 @@ internal static RemoteControlClient Initialize( private Timer? _keepAliveTimer; private KeepAliveMessage _ping = new(); - private record Connection(RemoteControlClient Owner, Uri EndPoint, Stopwatch Since, WebSocket? Socket) : IAsyncDisposable + private record Connection(RemoteControlClient Owner, Uri EndPoint, Stopwatch Since, WebSocket? Socket) + : IAsyncDisposable { private static class States { @@ -206,6 +210,7 @@ public async ValueTask DisposeAsync() await Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnected", CancellationToken.None); } catch { } + Socket.Dispose(); } } From 78612c88915a3df75790c182aaecbd879cc79d61 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 12:16:48 -0400 Subject: [PATCH 41/49] chore(tests): Pin MessagePack dependency to version 3.1.4 --- .../Uno.UI.RemoteControl.DevServer.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj index 4914eaf7896f..b471cd42404a 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Uno.UI.RemoteControl.DevServer.Tests.csproj @@ -9,6 +9,7 @@ + From e4349c281426a07a37e7b6b2c4c0ac7d7467db09 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 14:35:10 -0400 Subject: [PATCH 42/49] chore(build): Post-rebase conflict solving. - Reorganize solution filters. --- src/Uno.UI-Skia-only.slnf | 26 +++++++++++++----------- src/Uno.UI-UnitTests-only.slnf | 6 +++--- src/Uno.UI.RemoteControl.Host/Program.cs | 1 - 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Uno.UI-Skia-only.slnf b/src/Uno.UI-Skia-only.slnf index d1f56a675908..6de7d9b28a46 100644 --- a/src/Uno.UI-Skia-only.slnf +++ b/src/Uno.UI-Skia-only.slnf @@ -1,33 +1,33 @@ -{ +{ "solution": { "path": "Uno.UI.slnx", "projects": [ "AddIns\\Uno.UI.Foldable\\Uno.UI.Foldable.netcoremobile.csproj", "AddIns\\Uno.UI.GooglePlay\\Uno.UI.GooglePlay.netcoremobile.csproj", + "AddIns\\Uno.UI.Lottie\\Uno.UI.Lottie.netcoremobile.csproj", "AddIns\\Uno.UI.Lottie\\Uno.UI.Lottie.Reference.csproj", "AddIns\\Uno.UI.Lottie\\Uno.UI.Lottie.Skia.csproj", "AddIns\\Uno.UI.Lottie\\Uno.UI.Lottie.Tests.csproj", "AddIns\\Uno.UI.Lottie\\Uno.UI.Lottie.Wasm.csproj", - "AddIns\\Uno.UI.Lottie\\Uno.UI.Lottie.netcoremobile.csproj", - "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.Reference.csproj", - "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.Skia.csproj", - "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.Wasm.csproj", - "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.netcoremobile.csproj", "AddIns\\Uno.UI.Maps\\Uno.UI.Maps.netcoremobile.csproj", "AddIns\\Uno.UI.MediaPlayer.Skia.Win32\\Uno.UI.MediaPlayer.Skia.Win32.csproj", "AddIns\\Uno.UI.MediaPlayer.Skia.X11\\Uno.UI.MediaPlayer.Skia.X11.csproj", "AddIns\\Uno.UI.MediaPlayer.WebAssembly\\Uno.UI.MediaPlayer.WebAssembly.csproj", + "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.netcoremobile.csproj", + "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.Reference.csproj", + "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.Skia.csproj", + "AddIns\\Uno.UI.MSAL\\Uno.UI.MSAL.Wasm.csproj", + "AddIns\\Uno.UI.Svg\\Uno.UI.Svg.netcoremobile.csproj", "AddIns\\Uno.UI.Svg\\Uno.UI.Svg.Reference.csproj", "AddIns\\Uno.UI.Svg\\Uno.UI.Svg.Skia.csproj", - "AddIns\\Uno.UI.Svg\\Uno.UI.Svg.netcoremobile.csproj", "AddIns\\Uno.UI.WebView.Skia.X11\\Uno.UI.WebView.Skia.X11.csproj", "AddIns\\Uno.WinUI.Graphics2DSK\\Uno.WinUI.Graphics2DSK.Crossruntime.csproj", "AddIns\\Uno.WinUI.Graphics3DGL\\Uno.WinUI.Graphics3DGL.csproj", "SamplesApp\\Benchmarks.Shared\\SamplesApp.Benchmarks.shproj", "SamplesApp\\SamplesApp.Shared\\SamplesApp.Shared.shproj", "SamplesApp\\SamplesApp.Skia.Generic\\SamplesApp.Skia.Generic.csproj", - "SamplesApp\\SamplesApp.Skia.WebAssembly.Browser\\SamplesApp.Skia.WebAssembly.Browser.csproj", "SamplesApp\\SamplesApp.Skia.netcoremobile\\SamplesApp.Skia.netcoremobile.csproj", + "SamplesApp\\SamplesApp.Skia.WebAssembly.Browser\\SamplesApp.Skia.WebAssembly.Browser.csproj", "SamplesApp\\SamplesApp.Skia\\SamplesApp.Skia.csproj", "SamplesApp\\SamplesApp.UITests.Generator\\Uno.Samples.UITest.Generator.csproj", "SamplesApp\\SamplesApp.UITests\\SamplesApp.UITests.csproj", @@ -46,27 +46,29 @@ "SourceGenerators\\Uno.UI.Tasks\\Uno.UI.Tasks.csproj", "Uno.Foundation.Logging\\Uno.Foundation.Logging.csproj", "Uno.Foundation.Runtime.WebAssembly\\Uno.Foundation.Runtime.WebAssembly.csproj", + "Uno.Foundation\\Uno.Foundation.netcoremobile.csproj", "Uno.Foundation\\Uno.Foundation.Reference.csproj", "Uno.Foundation\\Uno.Foundation.Skia.csproj", "Uno.Foundation\\Uno.Foundation.Wasm.csproj", - "Uno.Foundation\\Uno.Foundation.netcoremobile.csproj", "Uno.Sdk\\Uno.Sdk.csproj", "Uno.UI.Adapter.Microsoft.Extensions.Logging\\Uno.UI.Adapter.Microsoft.Extensions.Logging.csproj", "Uno.UI.Composition\\Uno.UI.Composition.Reference.csproj", "Uno.UI.Composition\\Uno.UI.Composition.Skia.csproj", "Uno.UI.DevServer.Cli\\Uno.UI.DevServer.Cli.csproj", + "Uno.UI.Dispatching\\Uno.UI.Dispatching.netcoremobile.csproj", "Uno.UI.Dispatching\\Uno.UI.Dispatching.Reference.csproj", "Uno.UI.Dispatching\\Uno.UI.Dispatching.Skia.csproj", "Uno.UI.Dispatching\\Uno.UI.Dispatching.Wasm.csproj", - "Uno.UI.Dispatching\\Uno.UI.Dispatching.netcoremobile.csproj", "Uno.UI.FluentTheme.v1\\Uno.UI.FluentTheme.v1.Skia.csproj", "Uno.UI.FluentTheme.v2\\Uno.UI.FluentTheme.v2.Skia.csproj", "Uno.UI.FluentTheme\\Uno.UI.FluentTheme.Skia.csproj", "Uno.UI.RemoteControl.Controller\\Uno.UI.RemoteControl.Controller.csproj", + "Uno.UI.RemoteControl.DevServer.Tests\\Uno.UI.RemoteControl.DevServer.Tests.csproj", "Uno.UI.RemoteControl.Host\\Uno.UI.RemoteControl.Host.csproj", "Uno.UI.RemoteControl.Messaging\\Uno.UI.RemoteControl.Messaging.csproj", "Uno.UI.RemoteControl.Server.Processors\\Uno.UI.RemoteControl.Server.Processors.csproj", "Uno.UI.RemoteControl.Server\\Uno.UI.RemoteControl.Server.csproj", + "Uno.UI.RemoteControl.TestProcessor\\Uno.UI.RemoteControl.TestProcessor.csproj", "Uno.UI.RemoteControl.VS\\Uno.UI.RemoteControl.VS.csproj", "Uno.UI.RemoteControl\\Uno.UI.RemoteControl.Skia.csproj", "Uno.UI.Runtime.Skia.Android\\Uno.UI.Runtime.Skia.Android.csproj", @@ -87,10 +89,10 @@ "Uno.UI\\Uno.UI.Reference.csproj", "Uno.UI\\Uno.UI.Skia.csproj", "Uno.UWPSyncGenerator\\Uno.UWPSyncGenerator.csproj", + "Uno.UWP\\Uno.netcoremobile.csproj", "Uno.UWP\\Uno.Reference.csproj", "Uno.UWP\\Uno.Skia.csproj", - "Uno.UWP\\Uno.Wasm.csproj", - "Uno.UWP\\Uno.netcoremobile.csproj" + "Uno.UWP\\Uno.Wasm.csproj" ] } } \ No newline at end of file diff --git a/src/Uno.UI-UnitTests-only.slnf b/src/Uno.UI-UnitTests-only.slnf index 3abb603de0b1..75d82bb76db3 100644 --- a/src/Uno.UI-UnitTests-only.slnf +++ b/src/Uno.UI-UnitTests-only.slnf @@ -20,13 +20,13 @@ "Uno.UI.FluentTheme.v1\\Uno.UI.FluentTheme.v1.Tests.csproj", "Uno.UI.FluentTheme.v2\\Uno.UI.FluentTheme.v2.Tests.csproj", "Uno.UI.FluentTheme\\Uno.UI.FluentTheme.Tests.csproj", + "Uno.UI.RemoteControl.DevServer.Tests\\Uno.UI.RemoteControl.DevServer.Tests.csproj", "Uno.UI.Tests.ViewLibraryProps\\Uno.UI.Tests.ViewLibraryProps.csproj", "Uno.UI.Tests.ViewLibrary\\Uno.UI.Tests.ViewLibrary.csproj", "Uno.UI.Tests\\Uno.UI.Unit.Tests.csproj", "Uno.UI.Toolkit\\Uno.UI.Toolkit.Tests.csproj", "Uno.UI\\Uno.UI.Tests.csproj", - "Uno.UWP\\Uno.Tests.csproj", - "Uno.UI.RemoteControl.DevServer.Tests\\Uno.UI.RemoteControl.DevServer.Tests.csproj" + "Uno.UWP\\Uno.Tests.csproj" ] } -} +} \ No newline at end of file diff --git a/src/Uno.UI.RemoteControl.Host/Program.cs b/src/Uno.UI.RemoteControl.Host/Program.cs index c1750f34fea7..e55db0278cf7 100644 --- a/src/Uno.UI.RemoteControl.Host/Program.cs +++ b/src/Uno.UI.RemoteControl.Host/Program.cs @@ -173,7 +173,6 @@ static async Task Main(string[] args) .UseKestrel() .UseUrls($"http://*:{httpPort}/") .UseContentRoot(Directory.GetCurrentDirectory()) - .UseStartup() .ConfigureLogging(logging => { logging.ClearProviders(); From cc113091ff1d14b6282f458835e8f7ec44e7eefe Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 15:24:36 -0400 Subject: [PATCH 43/49] fix(logging): Include assembly file name in error message for failed assembly info read --- src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs index b095e6e4e242..6b0d50053f47 100644 --- a/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/RemoteControlExtensions.cs @@ -207,7 +207,8 @@ private static async Task HandleAppLaunchRegistrationRequest( { if (app.Log().IsEnabled(LogLevel.Error)) { - app.Log().LogError(ex, "Failed to read assembly info for path: {path}", assemblyPath); + var fileName = Path.GetFileName(assemblyPath); + app.Log().LogError(ex, "Failed to read assembly info for assembly file: {AssemblyFileName}", fileName); } context.Response.StatusCode = StatusCodes.Status500InternalServerError; From 3feb2ad674efe7d8e91154571e5d05912fb18517 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 15:26:51 -0400 Subject: [PATCH 44/49] fix(remote-control): Replace recursive re-scheduling with single periodic timers for keep-alive handling - Improves stability by handling disconnections and cancellations correctly. --- .../IDEChannel/IdeChannelServer.cs | 34 +++++++++++-------- .../IDEChannel/IDEChannelClient.cs | 25 ++++++++------ 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs index 2318ff433acf..17c1c6bbbf1b 100644 --- a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs +++ b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs @@ -135,25 +135,28 @@ private async Task InitializeServer(Guid channelId) private void ScheduleKeepAlive() { - // Capture and dispose the current timer to avoid race conditions + // Ensure a single periodic timer is running; dispose any previous instance safely var oldTimer = Interlocked.Exchange(ref _keepAliveTimer, null); oldTimer?.Dispose(); - - while (_pipeServer?.IsConnected ?? false) - { - _keepAliveTimer?.Dispose(); - } - _keepAliveTimer = new Timer(_ => - { - // Capture the timer instance to safely dispose it after scheduling the next one - var currentTimer = _keepAliveTimer; - SendKeepAlive(); - ScheduleKeepAlive(); + // Start a periodic keep-alive timer. If the pipe disconnects, stop the timer. + var timer = new Timer(_ => + { + if (_pipeServer?.IsConnected ?? false) + { + SendKeepAlive(); + } + else + { + Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); + } + }, null, KeepAliveDelay, KeepAliveDelay); - // Dispose the captured timer after the new one is scheduled - currentTimer?.Dispose(); - }, null, KeepAliveDelay, Timeout.Infinite); + // Publish the new timer instance; if another thread already set one, dispose this one + if (Interlocked.CompareExchange(ref _keepAliveTimer, timer, null) is not null) + { + timer.Dispose(); + } } private void SendKeepAlive() => _proxy?.SendToIde(new KeepAliveIdeMessage("dev-server")); @@ -165,6 +168,7 @@ public void Dispose() _configSubscription?.Dispose(); _rpcServer?.Dispose(); _pipeServer?.Dispose(); + Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); } private class Proxy(IdeChannelServer Owner) : IIdeChannelServer diff --git a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs index 49df9232f25f..b8e90b2c32d8 100644 --- a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs +++ b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs @@ -85,24 +85,27 @@ private void ScheduleKeepAlive(CancellationToken ct) { Connected?.Invoke(this, EventArgs.Empty); - // Capture and dispose the current timer to avoid race conditions + // Replace recursive re-scheduling with a single periodic timer var oldTimer = Interlocked.Exchange(ref _keepAliveTimer, null); oldTimer?.Dispose(); - _keepAliveTimer = new Timer(_ => + var timer = new Timer(state => { if (ct is { IsCancellationRequested: false } && _devServer is not null) { - // Capture the timer instance to safely dispose it after scheduling the next one - var currentTimer = _keepAliveTimer; - - var t = _devServer.SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), ct); - ScheduleKeepAlive(_IDEChannelCancellation!.Token); - - // Dispose the captured timer after the new one is scheduled - currentTimer?.Dispose(); + _ = _devServer.SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), ct); + } + else + { + // Stop the timer when disconnected or canceled + Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); } - }, null, KeepAliveDelay, Timeout.Infinite); + }, null, KeepAliveDelay, KeepAliveDelay); + + if (Interlocked.CompareExchange(ref _keepAliveTimer, timer, null) is not null) + { + timer.Dispose(); + } } private void ProcessDevServerMessage(object sender, IdeMessageEnvelope devServerMessageEnvelope) From 354bba83c0e19168a83d9fab8d43058329247f42 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 15:35:01 -0400 Subject: [PATCH 45/49] fix(remote-control): Signal connection establishment once and handle keep-alive send exceptions --- .../IDEChannel/IDEChannelClient.cs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs index b8e90b2c32d8..2355b43b7a0c 100644 --- a/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs +++ b/src/Uno.UI.RemoteControl.VS/IDEChannel/IDEChannelClient.cs @@ -57,6 +57,8 @@ public void ConnectToHost() _devServer = JsonRpc.Attach(_pipeServer); _devServer.MessageFromDevServer += ProcessDevServerMessage; + // Signal connection established once + Connected?.Invoke(this, EventArgs.Empty); ScheduleKeepAlive(ct); } catch (Exception e) @@ -83,8 +85,6 @@ public async Task SendToDevServerAsync(IdeMessage message, CancellationToken ct) private void ScheduleKeepAlive(CancellationToken ct) { - Connected?.Invoke(this, EventArgs.Empty); - // Replace recursive re-scheduling with a single periodic timer var oldTimer = Interlocked.Exchange(ref _keepAliveTimer, null); oldTimer?.Dispose(); @@ -93,7 +93,22 @@ private void ScheduleKeepAlive(CancellationToken ct) { if (ct is { IsCancellationRequested: false } && _devServer is not null) { - _ = _devServer.SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), ct); + try + { + _ = _devServer + .SendToDevServerAsync(IdeMessageSerializer.Serialize(new KeepAliveIdeMessage("IDE")), ct) + .ContinueWith(t => + { + if (t.IsFaulted && t.Exception is { } ex) + { + _logger.Verbose($"Keep-alive send failed: {ex.Flatten().Message}"); + } + }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + catch (Exception ex) + { + _logger.Verbose($"Keep-alive send exception: {ex.Message}"); + } } else { @@ -147,6 +162,12 @@ internal void Dispose() { _IDEChannelCancellation?.Cancel(); _keepAliveTimer?.Dispose(); + + if (_devServer is not null) + { + _devServer.MessageFromDevServer -= ProcessDevServerMessage; + } + _pipeServer?.Dispose(); } } From e58847cc2a8265cfc0c03a33808a99242c69ec8f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 22 Oct 2025 15:41:22 -0400 Subject: [PATCH 46/49] fix(app-launch): Make IsDebug optional and default to false instead of required --- src/Uno.Sdk/targets/Uno.AppLaunch.targets | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/Uno.Sdk/targets/Uno.AppLaunch.targets b/src/Uno.Sdk/targets/Uno.AppLaunch.targets index 0badc7def05b..5c8d048043e8 100644 --- a/src/Uno.Sdk/targets/Uno.AppLaunch.targets +++ b/src/Uno.Sdk/targets/Uno.AppLaunch.targets @@ -56,7 +56,7 @@ { [Required] public string Port { get; set; } = string.Empty; [Required] public string TargetPath { get; set; } = string.Empty; - [Required] public string IsDebug { get; set; } = string.Empty; + public string IsDebug { get; set; } = string.Empty; public string Ide { get; set; } = string.Empty; public string Plugin { get; set; } = string.Empty; @@ -89,17 +89,13 @@ Add("ide", Ide); Add("plugin", Plugin); - // Normalize IsDebug to "true"/"false" if it's a recognizable bool; otherwise pass as-is. - if (string.IsNullOrEmpty(IsDebug)) + // IsDebug is optional: treat empty/null as "false" + var isDebugValue = false; + if (!string.IsNullOrEmpty(IsDebug) && bool.TryParse(IsDebug, out var parsed)) { - Log.LogError("IsDebug is required."); - return Success = false; + isDebugValue = parsed; } - - if (bool.TryParse(IsDebug, out var b)) - Add("isDebug", b ? "true" : "false"); - else - Add("isDebug", IsDebug); + Add("isDebug", isDebugValue ? "true" : "false"); var qs = parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; var url = $"http://localhost:{portNum}/applaunch/asm/{encodedPath}{qs}"; From 602a48108a645eb3e15a44554a7d57ec87a1fc02 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 28 Oct 2025 13:12:15 -0400 Subject: [PATCH 47/49] chore(app-launch): Apply PR review suggestions --- .../Tasks/UnoNotifyAppLaunchToDevServer.cs | 87 ++++++++++++++ src/Uno.Sdk/targets/Uno.AppLaunch.targets | 109 +----------------- .../Helpers/DevServerTestHelper.cs | 2 +- .../Telemetry/TelemetryTestBase.cs | 2 +- .../Extensibility/AddIns.cs | 6 +- .../Extensibility/AddInsExtensions.cs | 19 +-- .../IDEChannel/IdeChannelServer.cs | 43 +++---- src/Uno.UI.RemoteControl.Host/Program.cs | 6 +- 8 files changed, 114 insertions(+), 160 deletions(-) create mode 100644 src/Uno.Sdk/Tasks/UnoNotifyAppLaunchToDevServer.cs diff --git a/src/Uno.Sdk/Tasks/UnoNotifyAppLaunchToDevServer.cs b/src/Uno.Sdk/Tasks/UnoNotifyAppLaunchToDevServer.cs new file mode 100644 index 000000000000..35305072f889 --- /dev/null +++ b/src/Uno.Sdk/Tasks/UnoNotifyAppLaunchToDevServer.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Uno.Sdk.Tasks; + +public sealed class UnoNotifyAppLaunchToDevServer_v0 : Task +{ + [Required] public string Port { get; set; } = string.Empty; + [Required] public string TargetPath { get; set; } = string.Empty; + public string IsDebug { get; set; } = string.Empty; + public string Ide { get; set; } = string.Empty; + public string Plugin { get; set; } = string.Empty; + + [Output] public bool Success { get; set; } + [Output] public string ResponseContent { get; set; } = string.Empty; + + public override bool Execute() + { + // Validate + if (string.IsNullOrWhiteSpace(Port) || !ushort.TryParse(Port, out var portNum) || portNum == 0) + { + Log.LogError("UnoRemoteControlPort must be a valid port number between 1 and 65535."); + return Success = false; + } + if (string.IsNullOrWhiteSpace(TargetPath)) + { + Log.LogError("TargetPath is required."); + return Success = false; + } + + var encodedPath = WebUtility.UrlEncode(TargetPath); + + var parts = new List(); + void Add(string name, string value) + { + if (!string.IsNullOrEmpty(value)) + parts.Add($"{name}={Uri.EscapeDataString(value)}"); + } + + Add("ide", Ide); + Add("plugin", Plugin); + + // IsDebug is optional: treat empty/null as "false" + var isDebugValue = false; + if (!string.IsNullOrEmpty(IsDebug) && bool.TryParse(IsDebug, out var parsed)) + { + isDebugValue = parsed; + } + Add("isDebug", isDebugValue ? "true" : "false"); + + var qs = parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; + var url = $"http://localhost:{portNum}/applaunch/asm/{encodedPath}{qs}"; + + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var response = client.GetAsync(url).GetAwaiter().GetResult(); + + Log.LogMessage(MessageImportance.High, + $"[NotifyDevServer] GET {url} -> {(int)response.StatusCode} {response.ReasonPhrase}"); + + if (response.IsSuccessStatusCode) + { + ResponseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + Success = true; + return true; + } + else + { + Success = false; + ResponseContent = string.Empty; + return false; + } + } + catch (Exception ex) + { + Log.LogWarning($"[NotifyDevServer] GET {url} failed: {ex.GetType().Name}: {ex.Message}"); + Success = false; + ResponseContent = string.Empty; + return false; + } + } +} diff --git a/src/Uno.Sdk/targets/Uno.AppLaunch.targets b/src/Uno.Sdk/targets/Uno.AppLaunch.targets index 5c8d048043e8..69ced3960d2e 100644 --- a/src/Uno.Sdk/targets/Uno.AppLaunch.targets +++ b/src/Uno.Sdk/targets/Uno.AppLaunch.targets @@ -31,109 +31,8 @@ $(UnoNotifyAppLaunchDependsOn);GetTargetPath - - - - - - - - - - - - (); - void Add(string name, string value) - { - if (!string.IsNullOrEmpty(value)) - parts.Add($"{name}={Uri.EscapeDataString(value)}"); - } - - Add("ide", Ide); - Add("plugin", Plugin); - - // IsDebug is optional: treat empty/null as "false" - var isDebugValue = false; - if (!string.IsNullOrEmpty(IsDebug) && bool.TryParse(IsDebug, out var parsed)) - { - isDebugValue = parsed; - } - Add("isDebug", isDebugValue ? "true" : "false"); - - var qs = parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; - var url = $"http://localhost:{portNum}/applaunch/asm/{encodedPath}{qs}"; - - try - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - var response = client.GetAsync(url).GetAwaiter().GetResult(); - - Log.LogMessage(MessageImportance.High, - $"[NotifyDevServer] GET {url} -> {(int)response.StatusCode} {response.ReasonPhrase}"); - - if (response.IsSuccessStatusCode) - { - ResponseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - Success = true; - return true; - } - else - { - Success = false; - ResponseContent = string.Empty; - return false; - } - } - catch (Exception ex) - { - Log.LogWarning($"[NotifyDevServer] GET {url} failed: {ex.GetType().Name}: {ex.Message}"); - Success = false; - ResponseContent = string.Empty; - return false; - } - } - } - ]]> - - - + @@ -151,7 +50,7 @@ Condition="!Exists('$(TargetPath)')"/> - - + diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs index ce3c1dd39986..545406765c48 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Helpers/DevServerTestHelper.cs @@ -62,7 +62,7 @@ public Guid? IdeChannelId { get { - if (_environmentVariables?.TryGetValue("UNO_PLATFORM_DEVSERVER_ideChannel", out var v) is true) + if (_environmentVariables?.TryGetValue("UNO_DEVSERVER_ideChannel", out var v) is true) { return Guid.TryParse(v, out var g) ? g : null; } diff --git a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs index 1524bc1da1b6..9f47a7af8871 100644 --- a/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs +++ b/src/Uno.UI.RemoteControl.DevServer.Tests/Telemetry/TelemetryTestBase.cs @@ -94,7 +94,7 @@ protected DevServerTestHelper CreateTelemetryHelperWithExactPath(string exactFil if (enableIdeChannel) { // Create an IDE channel GUID so the dev-server will initialize the named-pipe IDE channel - envVars["UNO_PLATFORM_DEVSERVER_ideChannel"] = Guid.NewGuid().ToString(); + envVars["UNO_DEVSERVER_ideChannel"] = Guid.NewGuid().ToString(); } return new DevServerTestHelper( diff --git a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs index cb42d302dc92..812a971de068 100644 --- a/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs +++ b/src/Uno.UI.RemoteControl.Host/Extensibility/AddIns.cs @@ -50,13 +50,11 @@ public static AddInsDiscoveryResult Discover(string solutionFile, ITelemetry? te else { var binlog = Path.GetTempFileName(); + // Important: the output file extension must be .binlog, otherwise the build will fail. result = ProcessHelper.RunProcess("dotnet", DumpTFM($"\"-bl:{binlog}.binlog\""), wd); _log.Log(LogLevel.Warning, msg); - if (result.error is { Length: > 0 }) - { - _log.Log(LogLevel.Debug, $"Error details: {result.error}"); - } + _log.Log(LogLevel.Debug, $"Error details: {result.output}"); } } diff --git a/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs b/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs index 1de928adfa29..e40da7c41442 100644 --- a/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs +++ b/src/Uno.UI.RemoteControl.Host/Extensibility/AddInsExtensions.cs @@ -12,25 +12,10 @@ namespace Uno.UI.RemoteControl.Host.Extensibility; public static class AddInsExtensions { - public static IWebHostBuilder ConfigureAddIns(this IWebHostBuilder builder, string solutionFile, ITelemetry? telemetry = null) - { - return builder.ConfigureServices(services => - { - var discovery = AddIns.Discover(solutionFile, telemetry); - var loadResults = AssemblyHelper.Load(discovery.AddIns, telemetry, throwIfLoadFailed: false); - - var assemblies = loadResults - .Where(result => result.Assembly is not null) - .Select(result => result.Assembly) - .ToImmutableArray(); - - services.AddFromAttributes(assemblies); - services.AddSingleton(new AddInsStatus(discovery, loadResults)); - }); - } - public static WebApplicationBuilder ConfigureAddIns(this WebApplicationBuilder builder, string solutionFile, ITelemetry? telemetry = null) { + // TODO: Move this to new pattern with a .AddAddIns() method. + var discovery = AddIns.Discover(solutionFile, telemetry); var loadResults = AssemblyHelper.Load(discovery.AddIns, telemetry, throwIfLoadFailed: false); diff --git a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs index 17c1c6bbbf1b..9b217928e42a 100644 --- a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs +++ b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs @@ -24,11 +24,24 @@ internal class IdeChannelServer : IIdeChannel, IDisposable private NamedPipeServerStream? _pipeServer; private JsonRpc? _rpcServer; private Proxy? _proxy; + private readonly Timer _keepAliveTimer; public IdeChannelServer(ILogger logger, IOptionsMonitor config) { _logger = logger; + _keepAliveTimer = new Timer(_ => + { + if (_pipeServer?.IsConnected ?? false) + { + SendKeepAlive(); + } + else + { + _keepAliveTimer!.Change(Timeout.Infinite, Timeout.Infinite); + } + }); + _initializeTask = Task.Run(() => InitializeServer(config.CurrentValue.ChannelId)); _configSubscription = config.OnChange(opts => _initializeTask = InitializeServer(opts.ChannelId)); } @@ -131,44 +144,18 @@ private async Task InitializeServer(Guid channelId) } private const int KeepAliveDelay = 10000; // 10 seconds in milliseconds - private Timer? _keepAliveTimer; - - private void ScheduleKeepAlive() - { - // Ensure a single periodic timer is running; dispose any previous instance safely - var oldTimer = Interlocked.Exchange(ref _keepAliveTimer, null); - oldTimer?.Dispose(); - - // Start a periodic keep-alive timer. If the pipe disconnects, stop the timer. - var timer = new Timer(_ => - { - if (_pipeServer?.IsConnected ?? false) - { - SendKeepAlive(); - } - else - { - Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); - } - }, null, KeepAliveDelay, KeepAliveDelay); - // Publish the new timer instance; if another thread already set one, dispose this one - if (Interlocked.CompareExchange(ref _keepAliveTimer, timer, null) is not null) - { - timer.Dispose(); - } - } + private void ScheduleKeepAlive() => _keepAliveTimer.Change(KeepAliveDelay, KeepAliveDelay); private void SendKeepAlive() => _proxy?.SendToIde(new KeepAliveIdeMessage("dev-server")); - /// public void Dispose() { + _keepAliveTimer.Dispose(); _configSubscription?.Dispose(); _rpcServer?.Dispose(); _pipeServer?.Dispose(); - Interlocked.Exchange(ref _keepAliveTimer, null)?.Dispose(); } private class Proxy(IdeChannelServer Owner) : IIdeChannelServer diff --git a/src/Uno.UI.RemoteControl.Host/Program.cs b/src/Uno.UI.RemoteControl.Host/Program.cs index e55db0278cf7..892c474916a2 100644 --- a/src/Uno.UI.RemoteControl.Host/Program.cs +++ b/src/Uno.UI.RemoteControl.Host/Program.cs @@ -184,17 +184,15 @@ static async Task Main(string[] args) .ConfigureAppConfiguration((hostingContext, config) => { config.AddCommandLine(args); - config.AddEnvironmentVariables("UNO_PLATFORM_DEVSERVER_"); + config.AddEnvironmentVariables("UNO_DEVSERVER_"); }) .ConfigureServices(services => { - services.AddSingleton(); - services.AddSingleton(); services.AddRouting(); services.Configure(builder.Configuration); }); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(_ => globalServiceProvider.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton( From 130d34b3968d5bfd49b8423fa49149e1e9a879b7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 29 Oct 2025 15:33:09 -0400 Subject: [PATCH 48/49] feat(remote-control): Introduce configuration helpers for parsing and simplify command line handling - Refactored `Program.cs` to streamline argument processing using `IConfiguration`. - Removed requirement of the ChannelID to be a Guid --- .../Helpers/ConfigurationExtensions.cs | 51 ++++++++ .../IDEChannel/IdeChannelServer.cs | 10 +- .../IDEChannel/IdeChannelServerOptions.cs | 8 +- src/Uno.UI.RemoteControl.Host/Program.cs | 119 +++++++----------- 4 files changed, 102 insertions(+), 86 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.Host/Helpers/ConfigurationExtensions.cs diff --git a/src/Uno.UI.RemoteControl.Host/Helpers/ConfigurationExtensions.cs b/src/Uno.UI.RemoteControl.Host/Helpers/ConfigurationExtensions.cs new file mode 100644 index 000000000000..ddbbcb38d979 --- /dev/null +++ b/src/Uno.UI.RemoteControl.Host/Helpers/ConfigurationExtensions.cs @@ -0,0 +1,51 @@ +using System; +using System.Globalization; +using Microsoft.Extensions.Configuration; + +namespace Uno.UI.RemoteControl.Host.Helpers; + +internal static class ConfigurationExtensions +{ + public static int ParseOptionalInt(this IConfiguration configuration, string key) + { + var value = configuration[key]; + + if (string.IsNullOrWhiteSpace(value)) + { + return 0; + } + + if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + { + throw new ArgumentException($"The {key} parameter is invalid {value}"); + } + + return result; + } + + public static int ParseIntOrDefault(this IConfiguration configuration, string key, int defaultValue) + { + var value = configuration[key]; + + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) + ? result + : defaultValue; + } + + public static string? GetOptionalString(this IConfiguration configuration, string key) + { + var value = configuration[key]; + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } +} diff --git a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs index 9b217928e42a..2d9f7d12124c 100644 --- a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs +++ b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServer.cs @@ -82,14 +82,14 @@ public async ValueTask WaitForReady(CancellationToken ct = default) /// /// Initialize as dev-server (cf. IdeChannelClient for init as IDE) /// - private async Task InitializeServer(Guid channelId) + private async Task InitializeServer(string? channelId) { try { - // First we remove the proxy to prevent messages being sent while we are re-initializing + // First, we remove the proxy to prevent messages being sent while we are re-initializing _proxy = null; - // Dispose any existing server + // Disposing any previous server _rpcServer?.Dispose(); if (_pipeServer is { } server) { @@ -104,13 +104,13 @@ private async Task InitializeServer(Guid channelId) try { - if (channelId == Guid.Empty) + if (string.IsNullOrWhiteSpace(channelId)) { return false; } _pipeServer = new NamedPipeServerStream( - pipeName: channelId.ToString(), + pipeName: channelId, direction: PipeDirection.InOut, maxNumberOfServerInstances: 1, transmissionMode: PipeTransmissionMode.Byte, diff --git a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServerOptions.cs b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServerOptions.cs index 5025a38296fe..bdb39c8caddd 100644 --- a/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServerOptions.cs +++ b/src/Uno.UI.RemoteControl.Host/IDEChannel/IdeChannelServerOptions.cs @@ -1,8 +1,6 @@ -using System; +namespace Uno.UI.RemoteControl.Host.IdeChannel; -namespace Uno.UI.RemoteControl.Host.IdeChannel; - -public class IdeChannelServerOptions +public sealed class IdeChannelServerOptions { - public Guid ChannelId { get; set; } + public string? ChannelId { get; set; } } diff --git a/src/Uno.UI.RemoteControl.Host/Program.cs b/src/Uno.UI.RemoteControl.Host/Program.cs index 892c474916a2..ae2bb47cd2f8 100644 --- a/src/Uno.UI.RemoteControl.Host/Program.cs +++ b/src/Uno.UI.RemoteControl.Host/Program.cs @@ -3,18 +3,14 @@ using System.Diagnostics; using System.Globalization; using System.IO; -using System.Net; -using System.Net.Sockets; using System.Reflection; using Microsoft.AspNetCore.Builder; -using System.Linq; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Mono.Options; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using System.Threading; using System.Threading.Tasks; using System.Runtime.Versioning; @@ -22,6 +18,7 @@ using Uno.UI.RemoteControl.Host.Extensibility; using Uno.UI.RemoteControl.Host.IdeChannel; using Uno.UI.RemoteControl.Server.Helpers; +using Uno.UI.RemoteControl.Host.Helpers; using Uno.UI.RemoteControl.Server.Telemetry; using Uno.UI.RemoteControl.Services; using Uno.UI.RemoteControl.Helpers; @@ -42,63 +39,27 @@ static async Task Main(string[] args) try { - var httpPort = 0; - var parentPID = 0; - var solution = default(string); - var ideChannel = Guid.Empty; - var command = default(string); - var workingDir = default(string); - var timeoutMs = 30000; - - var p = new OptionSet + var switchMappings = new Dictionary(StringComparer.OrdinalIgnoreCase) { - { - "httpPort=", s => { - if(!int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out httpPort)) - { - throw new ArgumentException($"The httpPort parameter is invalid {s}"); - } - } - }, - { - "ppid=", s => { - if(!int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out parentPID)) - { - throw new ArgumentException($"The parent process id parameter is invalid {s}"); - } - } - }, - { - "solution=", s => - { - if (string.IsNullOrWhiteSpace(s) || !File.Exists(s)) - { - throw new ArgumentException($"The provided solution path '{s}' does not exists"); - } - - solution = s; - } - }, - { - "ideChannel=", s => { - if(!Guid.TryParse(s, out ideChannel)) - { - throw new ArgumentException($"The ide channel parameter is invalid {s}"); - } - } - }, - { - "c|command=", s => command = s - }, - { - "workingDir=", s => workingDir = s - }, - { - "timeoutMs=", s => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out timeoutMs) - } + ["-c"] = "command", }; - p.Parse(args); + var globalConfiguration = new ConfigurationBuilder() + .AddEnvironmentVariables("UNO_DEVSERVER_") + .AddCommandLine(args, switchMappings) + .Build(); + + var httpPort = globalConfiguration.ParseOptionalInt("httpPort"); + var parentPID = globalConfiguration.ParseOptionalInt("ppid"); + var command = globalConfiguration.GetOptionalString("command"); + var workingDir = globalConfiguration.GetOptionalString("workingDir"); + var timeoutMs = globalConfiguration.ParseIntOrDefault("timeoutMs", 30000); + + var solution = globalConfiguration.GetOptionalString("solution"); + if (!string.IsNullOrWhiteSpace(solution) && !File.Exists(solution)) + { + throw new ArgumentException($"The provided solution path '{solution}' does not exists"); + } // Controller mode if (!string.IsNullOrWhiteSpace(command)) @@ -119,7 +80,8 @@ static async Task Main(string[] args) await CleanupCommandAsync(); return; default: - await Console.Error.WriteLineAsync($"Unknown command '{command}'. Supported: start, stop, list, cleanup"); + await Console.Error.WriteLineAsync( + $"Unknown command '{command}'. Supported: start, stop, list, cleanup"); Environment.ExitCode = 1; return; } @@ -127,17 +89,19 @@ static async Task Main(string[] args) if (httpPort == 0) { - throw new ArgumentException($"The httpPort parameter is required."); + throw new ArgumentException("The httpPort parameter is required."); } const LogLevel logLevel = LogLevel.Debug; // During init, we dump the logs to the console, until the logger is set up - Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(logLevel).AddConsole()); + Uno.Extensions.LogExtensionPoint.AmbientLoggerFactory = + LoggerFactory.Create(builder => builder.SetMinimumLevel(logLevel).AddConsole()); // STEP 1: Create the global service provider BEFORE WebApplication // This contains services that live for the entire duration of the server process var globalServices = new ServiceCollection(); + globalServices.AddSingleton(globalConfiguration); // Add logging services to the global container // This is necessary for services like IdeChannelServer that require ILogger @@ -146,7 +110,11 @@ static async Task Main(string[] args) .SetMinimumLevel(LogLevel.Debug)); globalServices.AddGlobalTelemetry(); // Global telemetry services (Singleton) - globalServices.AddOptions().Configure(opts => opts.ChannelId = ideChannel); + globalServices.AddOptions() + .Configure((opts, configuration) => + { + opts.ChannelId = configuration.GetOptionalString("ideChannel"); + }); globalServices.AddSingleton(); #pragma warning disable ASP0000 // Do not call ConfigureServices after calling UseKestrel. @@ -183,8 +151,7 @@ static async Task Main(string[] args) }) .ConfigureAppConfiguration((hostingContext, config) => { - config.AddCommandLine(args); - config.AddEnvironmentVariables("UNO_DEVSERVER_"); + config.AddConfiguration(globalConfiguration); }) .ConfigureServices(services => { @@ -192,11 +159,12 @@ static async Task Main(string[] args) services.Configure(builder.Configuration); }); - builder.Services.AddSingleton(_ => globalServiceProvider.GetRequiredService()); + builder.Services.AddSingleton(_ => + globalServiceProvider.GetRequiredService()); builder.Services.AddSingleton(); - builder.Services.AddSingleton( - _ => globalServiceProvider.GetRequiredService()); + builder.Services.AddSingleton(_ => + globalServiceProvider.GetRequiredService()); // Add the global service provider to the DI container builder.Services.AddKeyedSingleton("global", globalServiceProvider); @@ -214,7 +182,8 @@ static async Task Main(string[] args) } else { - typeof(Program).Log().Log(LogLevel.Warning, "No solution file specified, add-ins will not be loaded which means that you won't be able to use any of the uno-studio features. Usually this indicates that your version of uno's IDE extension is too old."); + typeof(Program).Log().Log(LogLevel.Warning, + "No solution file specified, add-ins will not be loaded which means that you won't be able to use any of the uno-studio features. Usually this indicates that your version of uno's IDE extension is too old."); builder.Services.AddSingleton(AddInsStatus.Empty); } #pragma warning restore ASPDEPR004 @@ -230,11 +199,12 @@ static async Task Main(string[] args) // Once the app has started, we use the logger from the host LogExtensionPoint.AmbientLoggerFactory = host.Services.GetRequiredService(); - _ = host.Services.GetRequiredService().StartAsync(ct.Token); // Background services are not supported by WebHostBuilder + _ = host.Services.GetRequiredService() + .StartAsync(ct.Token); // Background services are not supported by WebHostBuilder // Display DevServer version banner - var config = host.Services.GetRequiredService(); - var ideChannelId = config["ideChannel"]; // GUID used as the named pipe name when IDE channel is enabled + var ideChannelOptions = host.Services.GetRequiredService>(); + var ideChannelId = ideChannelOptions.CurrentValue.ChannelId; DisplayVersionBanner(httpPort, ideChannelId); // STEP 3: Use global telemetry for server-wide events @@ -288,10 +258,7 @@ static async Task Main(string[] args) ["StartupErrorType"] = ex.GetType().Name, ["StartupStackTrace"] = ex.StackTrace ?? "", }; - var errorMeasurements = new Dictionary - { - ["UptimeSeconds"] = uptime.TotalSeconds, - }; + var errorMeasurements = new Dictionary { ["UptimeSeconds"] = uptime.TotalSeconds, }; telemetry.TrackEvent("startup-failure", errorProperties, errorMeasurements); await telemetry.FlushAsync(CancellationToken.None); From b626a6d1852587423b603215eea249b1360fda12 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 31 Oct 2025 10:16:59 -0400 Subject: [PATCH 49/49] fix(app-launch): Rename MSBuild task properties for consistent Uno-prefixed naming --- src/Uno.Sdk/targets/Uno.AppLaunch.targets | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Uno.Sdk/targets/Uno.AppLaunch.targets b/src/Uno.Sdk/targets/Uno.AppLaunch.targets index 69ced3960d2e..60ad534ee31f 100644 --- a/src/Uno.Sdk/targets/Uno.AppLaunch.targets +++ b/src/Uno.Sdk/targets/Uno.AppLaunch.targets @@ -17,13 +17,13 @@ Other IDEs will call this target by specifying the `UnoRemoteControlPort` property using the following command: ```shell - dotnet build -c:Debug -t:UnoNotifyAppLaunch -p:TargetFramework= -p:NoBuild=true -restore:false -v:d -noLogo -low -p:Ide= -p:Plugin= -p:IsDebug=false + dotnet build -c:Debug -t:UnoNotifyAppLaunch -p:TargetFramework= -p:NoBuild=true -restore:false -v:d -noLogo -low -p:UnoIde= -p:UnoPlugin= -p:UnoIsDebug=false ``` To retrieve the HTTP response content (MVID and target framework info), use -getProperty: ```shell - dotnet build -c:Debug -t:UnoNotifyAppLaunch -getProperty:UnoNotifyAppLaunchHttpResponse -p:TargetFramework= -p:NoBuild=true -restore:false -p:Ide= -p:Plugin= -p:IsDebug=false + dotnet build -c:Debug -t:UnoNotifyAppLaunch -getProperty:UnoNotifyAppLaunchHttpResponse -p:TargetFramework= -p:NoBuild=true -restore:false -p:UnoIde= -p:UnoPlugin= -p:UnoIsDebug=false ``` --> @@ -53,9 +53,9 @@ + Ide="$(UnoIde)" + Plugin="$(UnoPlugin)" + IsDebug="$(UnoIsDebug)">