diff --git a/README.md b/README.md index e736051..0f44adf 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ builder.AddMauiBlazorDevFlowTools(); // Blazor Hybrid only #endif ``` -**Agent options:** `Port` (default 9223), `Enabled` (default true), `MaxTreeDepth` (0 = unlimited). Port is also configurable via `.mauidevflow` or `-p:MauiDevFlowPort=XXXX`. +**Agent options:** `Port` (default 9223), `Enabled` (default true), `MaxTreeDepth` (0 = unlimited), `EnableProfiler` (default false), `ProfilerSampleIntervalMs` (default 500), `MaxProfilerSamples` (default 20000), `MaxProfilerMarkers` (default 20000), `MaxProfilerSpans` (default 20000), `EnableHighLevelUiHooks` (default true), `EnableDetailedUiHooks` (default false). Port is also configurable via `.mauidevflow` or `-p:MauiDevFlowPort=XXXX`. + +With `EnableProfiler=true`, the agent uses native frame pipelines where available (Android `FrameMetrics` on API 24+, Android `Choreographer` fallback, Apple `CADisplayLink`, Windows `CompositionTarget.Rendering`) and emits frame/jank/stall signals (`frameSource`, `jankFrameCount`, `uiThreadStallCount`). Android `FrameMetrics` is treated as exact native timing; cadence-based providers (Apple/Windows/Android fallback) are reported with non-exact frame quality so consumers can distinguish confidence levels. High-level UI milestones (navigation/page/scroll) are enabled by default; per-control hooks are optional via `EnableDetailedUiHooks=true` when deep interaction traces are needed. **Blazor options:** `Enabled` (default true), `EnableWebViewInspection` (default true), `EnableLogging` (default true in DEBUG). CDP commands are routed through the agent port — no separate Blazor port needed. @@ -282,6 +284,13 @@ auto-assigned by the broker (range 10223–10899), or configurable via `.mauidev | `/api/cdp` | POST | Forward CDP command to Blazor WebView. Use `?webview=` to target a specific WebView | | `/api/cdp/webviews` | GET | List registered CDP WebViews (index, AutomationId, elementId, ready status) | | `/api/cdp/source` | GET | Get page HTML source. Use `?webview=` to target a specific WebView | +| `/api/profiler/capabilities` | GET | Profiling capability matrix and availability (`EnableProfiler`) | +| `/api/profiler/start` | POST | Start profiling session. Optional body: `{"sampleIntervalMs":500}` | +| `/api/profiler/stop` | POST | Stop active profiling session | +| `/api/profiler/samples?sampleCursor=S&markerCursor=M&spanCursor=P&limit=N` | GET | Poll sample + marker + span batch since cursors | +| `/api/profiler/marker` | POST | Publish manual marker `{"type":"user.action","name":"...","payloadJson":"..."}` | +| `/api/profiler/span` | POST | Publish manual span `{"kind":"ui.operation","name":"...","startTsUtc":"...","endTsUtc":"..."}` | +| `/api/profiler/hotspots?kind=ui.operation&minDurationMs=16&limit=20` | GET | Aggregated slow-operation hotspots ordered by P95 duration | ## Project Structure diff --git a/src/MauiDevFlow.Agent.Core/AgentOptions.cs b/src/MauiDevFlow.Agent.Core/AgentOptions.cs index 0a3aa1c..c3bac6a 100644 --- a/src/MauiDevFlow.Agent.Core/AgentOptions.cs +++ b/src/MauiDevFlow.Agent.Core/AgentOptions.cs @@ -75,4 +75,44 @@ public class AgentOptions /// Maximum number of network requests to keep in the ring buffer. Default: 500. /// public int MaxNetworkBufferSize { get; set; } = 500; + + /// + /// Enables runtime profiling endpoints and sampling. Default: false. + /// + public bool EnableProfiler { get; set; } = false; + + /// + /// Default profiler sampling interval in milliseconds. Default: 500ms. + /// + public int ProfilerSampleIntervalMs { get; set; } = 500; + + /// + /// Maximum number of profiler samples to keep in memory. Default: 20,000. + /// Uses overwrite-on-full ring buffer behavior. + /// + public int MaxProfilerSamples { get; set; } = 20_000; + + /// + /// Maximum number of profiler markers to keep in memory. Default: 20,000. + /// Uses overwrite-on-full ring buffer behavior. + /// + public int MaxProfilerMarkers { get; set; } = 20_000; + + /// + /// Maximum number of profiler spans to keep in memory. Default: 20,000. + /// Uses overwrite-on-full ring buffer behavior. + /// + public int MaxProfilerSpans { get; set; } = 20_000; + + /// + /// Enables high-level MAUI UI correlation hooks (navigation/page/scroll markers). + /// Default: true. + /// + public bool EnableHighLevelUiHooks { get; set; } = true; + + /// + /// Enables detailed per-control MAUI hooks (button/entry/toggle/picker/tap). + /// Default: false to avoid broad attachment overhead. + /// + public bool EnableDetailedUiHooks { get; set; } = false; } diff --git a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs index 850d35e..721f77e 100644 --- a/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs @@ -1,9 +1,11 @@ using System.Text.Json; using System.Reflection; +using System.Runtime.CompilerServices; using Microsoft.Maui; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Internals; using Microsoft.Maui.Dispatching; +using MauiDevFlow.Agent.Core.Profiling; using MauiDevFlow.Logging; using MauiDevFlow.Agent.Core.Network; @@ -13,7 +15,7 @@ namespace MauiDevFlow.Agent.Core; /// The main agent service that hosts the HTTP API and coordinates /// visual tree inspection and element interactions. /// -public class DevFlowAgentService : IDisposable +public class DevFlowAgentService : IDisposable, IMarkerPublisher { private readonly AgentOptions _options; private readonly AgentHttpServer _server; @@ -29,6 +31,70 @@ public class DevFlowAgentService : IDisposable /// public NetworkRequestStore NetworkStore { get; } + private readonly IProfilerCollector _profilerCollector; + private readonly ProfilerSessionStore _profilerSessions; + private readonly SemaphoreSlim _profilerStateGate = new(1, 1); + private CancellationTokenSource? _profilerLoopCts; + private Task? _profilerLoopTask; + private DateTime _lastAutoJankSpanTsUtc = DateTime.MinValue; + private const int UiHookScanIntervalMs = 3000; + private readonly ConditionalWeakTable _uiHookStates = new(); + private readonly List _uiHookUnsubscribers = new(); + private readonly object _uiHookGate = new(); + private int _uiHookGeneration = 1; + private int _uiHookScanInFlight; + private DateTime _lastUiHookScanTsUtc = DateTime.MinValue; + private Shell? _hookedShell; + private DateTime? _navigationStartedAtUtc; + private string? _navigationTargetRoute; + private DateTime _lastUserActionTsUtc = DateTime.MinValue; + private string? _lastUserActionName; + private string? _lastUserActionElementPath; + private readonly ConditionalWeakTable _pageLifecycleStates = new(); + private readonly ConditionalWeakTable _elementRenderStates = new(); + private readonly ConditionalWeakTable _scrollBatchStates = new(); + + private sealed class UiHookState + { + public int Generation { get; set; } + public HashSet HookKeys { get; } = new(StringComparer.Ordinal); + } + + private sealed class PageLifecycleState + { + public DateTime AppearingAtUtc { get; set; } + public string? Route { get; set; } + public bool FirstLayoutPublished { get; set; } + public int SizeChangedCount { get; set; } + public int MeasureInvalidatedCount { get; set; } + } + + private sealed class ElementRenderState + { + public DateTime TrackingStartedAtUtc { get; set; } + public string? Role { get; set; } + public bool FirstLayoutPublished { get; set; } + public int SizeChangedCount { get; set; } + public int MeasureInvalidatedCount { get; set; } + } + + private sealed class ScrollBatchState + { + public bool IsActive { get; set; } + public DateTime StartedAtUtc { get; set; } + public DateTime LastEventAtUtc { get; set; } + public int EventCount { get; set; } + public int FlushVersion { get; set; } + public double StartOffsetX { get; set; } + public double StartOffsetY { get; set; } + public double LastOffsetX { get; set; } + public double LastOffsetY { get; set; } + public int? StartFirstVisibleIndex { get; set; } + public int? StartLastVisibleIndex { get; set; } + public int? LastFirstVisibleIndex { get; set; } + public int? LastLastVisibleIndex { get; set; } + } + /// /// Delegate for sending CDP commands to the Blazor WebView. /// Set by the Blazor package when both are registered. @@ -135,8 +201,14 @@ public DevFlowAgentService(AgentOptions? options = null) _server = new AgentHttpServer(_options.Port); _treeWalker = CreateTreeWalker(); NetworkStore = new NetworkRequestStore(_options.MaxNetworkBufferSize); + _profilerCollector = CreateProfilerCollector(); + _profilerSessions = new ProfilerSessionStore( + Math.Max(1, _options.MaxProfilerSamples), + Math.Max(1, _options.MaxProfilerMarkers), + Math.Max(1, _options.MaxProfilerSpans)); if (_options.EnableNetworkMonitoring) DevFlowHttp.SetStore(NetworkStore); + NetworkStore.OnRequestCaptured += HandleCapturedNetworkRequest; RegisterRoutes(); } @@ -168,6 +240,12 @@ public DevFlowAgentService(AgentOptions? options = null) /// protected virtual VisualTreeWalker CreateTreeWalker() => new VisualTreeWalker(); + /// + /// Creates the profiler collector. Override in platform-specific subclasses + /// to provide native frame/CPU integrations. + /// + protected virtual IProfilerCollector CreateProfilerCollector() => new RuntimeProfilerCollector(); + /// Platform name for status reporting. Override for platforms without DeviceInfo. protected virtual string PlatformName => DeviceInfo.Current.Platform.ToString(); @@ -193,6 +271,8 @@ protected virtual double GetWindowDisplayDensity(IWindow? window) /// Gets native window dimensions when MAUI reports 0. Override for platform-specific access. protected virtual (double width, double height) GetNativeWindowSize(IWindow window) => (0, 0); + private bool IsProfilerFeatureAvailable => _options.EnableProfiler; + /// /// Sets the file log provider for serving logs via the API. /// Called by AgentServiceExtensions during registration. @@ -243,6 +323,7 @@ public void Start(Application app, IDispatcher dispatcher) public async Task StopAsync() { + await StopProfilerAsync(); await _server.StopAsync(); } @@ -267,6 +348,13 @@ private void RegisterRoutes() _server.MapPost("/api/cdp", HandleCdp); _server.MapGet("/api/cdp/webviews", HandleCdpWebViews); _server.MapGet("/api/cdp/source", HandleCdpSource); + _server.MapGet("/api/profiler/capabilities", HandleProfilerCapabilities); + _server.MapPost("/api/profiler/start", HandleProfilerStart); + _server.MapPost("/api/profiler/stop", HandleProfilerStop); + _server.MapGet("/api/profiler/samples", HandleProfilerSamples); + _server.MapPost("/api/profiler/marker", HandleProfilerMarker); + _server.MapPost("/api/profiler/span", HandleProfilerSpan); + _server.MapGet("/api/profiler/hotspots", HandleProfilerHotspots); // Network monitoring _server.MapGet("/api/network", HandleNetworkList); @@ -312,7 +400,9 @@ private async Task HandleStatus(HttpRequest request) cdpWebViewCount = _cdpWebViews.Count, windowCount = _app?.Windows.Count ?? 0, windowWidth = double.IsFinite(w) ? w : 0, - windowHeight = double.IsFinite(h) ? h : 0 + windowHeight = double.IsFinite(h) ? h : 0, + profiler = BuildProfilerCapabilitiesPayload(), + profilerSession = _profilerSessions.CurrentSession }; }); @@ -899,6 +989,7 @@ private async Task HandleSetProperty(HttpRequest request) if (body?.Value == null) return HttpResponse.Error("value is required"); + var startedAtUtc = DateTime.UtcNow; var result = await DispatchAsync(() => { var el = _treeWalker.GetElementById(id, _app); @@ -921,6 +1012,14 @@ private async Task HandleSetProperty(HttpRequest request) } }); + PublishUiOperationSpan( + "action.set-property", + startedAtUtc, + result == "ok", + result == "ok" ? null : result, + id, + new { property = propName }); + return result == "ok" ? HttpResponse.Json(new { id, property = propName, value = body.Value }) : HttpResponse.Error(result); @@ -997,6 +1096,7 @@ private async Task HandleTap(HttpRequest request) if (body?.ElementId == null) return HttpResponse.Error("elementId is required"); + var startedAtUtc = DateTime.UtcNow; var result = await DispatchAsync(() => { var el = _treeWalker.GetElementById(body.ElementId, _app); @@ -1094,6 +1194,13 @@ private async Task HandleTap(HttpRequest request) } }); + PublishUiOperationSpan( + "action.tap", + startedAtUtc, + result == "ok", + result == "ok" ? null : result, + body.ElementId); + return result == "ok" ? HttpResponse.Ok("Tapped") : HttpResponse.Error(result); } @@ -1145,6 +1252,7 @@ private async Task HandleFill(HttpRequest request) if (body?.ElementId == null || body.Text == null) return HttpResponse.Error("elementId and text are required"); + var startedAtUtc = DateTime.UtcNow; var result = await DispatchAsync(() => { var el = _treeWalker.GetElementById(body.ElementId, _app); @@ -1169,6 +1277,14 @@ private async Task HandleFill(HttpRequest request) } }); + PublishUiOperationSpan( + "action.fill", + startedAtUtc, + result == "ok", + result == "ok" ? null : result, + body.ElementId, + new { textLength = body.Text.Length }); + return result == "ok" ? HttpResponse.Ok("Text set") : HttpResponse.Error(result); } @@ -1180,6 +1296,7 @@ private async Task HandleClear(HttpRequest request) if (body?.ElementId == null) return HttpResponse.Error("elementId is required"); + var startedAtUtc = DateTime.UtcNow; var success = await DispatchAsync(() => { var el = _treeWalker.GetElementById(body.ElementId, _app); @@ -1201,6 +1318,13 @@ private async Task HandleClear(HttpRequest request) } }); + PublishUiOperationSpan( + "action.clear", + startedAtUtc, + success, + success ? null : "Element does not accept text input", + body.ElementId); + return success ? HttpResponse.Ok("Cleared") : HttpResponse.Error("Element does not accept text input"); } @@ -1212,6 +1336,7 @@ private async Task HandleFocus(HttpRequest request) if (body?.ElementId == null) return HttpResponse.Error("elementId is required"); + var startedAtUtc = DateTime.UtcNow; var success = await DispatchAsync(() => { var el = _treeWalker.GetElementById(body.ElementId, _app); @@ -1220,6 +1345,13 @@ private async Task HandleFocus(HttpRequest request) return true; }); + PublishUiOperationSpan( + "action.focus", + startedAtUtc, + success, + success ? null : "Cannot focus element", + body.ElementId); + return success ? HttpResponse.Ok("Focused") : HttpResponse.Error("Cannot focus element"); } @@ -1231,6 +1363,15 @@ private async Task HandleNavigate(HttpRequest request) if (string.IsNullOrEmpty(body?.Route)) return HttpResponse.Error("route is required"); + var startedAtUtc = DateTime.UtcNow; + Publish(new ProfilerMarker + { + TsUtc = DateTime.UtcNow, + Type = "navigation.start", + Name = body.Route, + PayloadJson = JsonSerializer.Serialize(new { route = body.Route }) + }); + var result = await DispatchAsync(async () => { try @@ -1248,6 +1389,22 @@ private async Task HandleNavigate(HttpRequest request) } }); + Publish(new ProfilerMarker + { + TsUtc = DateTime.UtcNow, + Type = "navigation.end", + Name = body.Route, + PayloadJson = JsonSerializer.Serialize(new { route = body.Route, success = result == "ok", error = result == "ok" ? null : result }) + }); + + PublishUiOperationSpan( + "action.navigate", + startedAtUtc, + result == "ok", + result == "ok" ? null : result, + elementPath: body.Route, + tags: new { route = body.Route }); + return result == "ok" ? HttpResponse.Ok($"Navigated to {body.Route}") : HttpResponse.Error(result ?? "Navigation failed"); } @@ -1259,6 +1416,7 @@ private async Task HandleResize(HttpRequest request) if (body == null || body.Width <= 0 || body.Height <= 0) return HttpResponse.Error("width and height are required (positive integers)"); + var startedAtUtc = DateTime.UtcNow; var windowIndex = ParseWindowIndex(request); var result = await DispatchAsync(() => { @@ -1278,6 +1436,13 @@ private async Task HandleResize(HttpRequest request) } }); + PublishUiOperationSpan( + "action.resize", + startedAtUtc, + result == "ok", + result == "ok" ? null : result, + tags: new { width = body.Width, height = body.Height, windowIndex }); + return result == "ok" ? HttpResponse.Json(new { success = true, width = body.Width, height = body.Height }) : HttpResponse.Error(result); @@ -1307,7 +1472,7 @@ private async Task HandleScroll(HttpRequest request) return HttpResponse.Error("Request body is required"); var position = ParseScrollToPosition(body.ScrollToPosition); - + var startedAtUtc = DateTime.UtcNow; var result = await DispatchAsync(async () => { // Priority 1: Scroll by item index on a specific ItemsView @@ -1436,6 +1601,14 @@ await ScrollWithTimeoutAsync( return "No scrollable view found on page"; }); + PublishUiOperationSpan( + "action.scroll", + startedAtUtc, + result == "ok", + result == "ok" ? null : result, + body.ElementId, + new { body.DeltaX, body.DeltaY, body.Animated }); + return result == "ok" ? HttpResponse.Ok("Scrolled") : HttpResponse.Error(result ?? "Scroll failed"); } @@ -1572,10 +1745,1265 @@ protected async Task DispatchAsync(Func func) return await tcs.Task; } + private object BuildProfilerCapabilitiesPayload() + { + var capabilities = _profilerCollector.GetCapabilities(); + return new + { + available = IsProfilerFeatureAvailable, + supportedInBuild = true, + featureEnabled = _options.EnableProfiler, + platform = capabilities.Platform, + managedMemorySupported = capabilities.ManagedMemorySupported, + nativeMemorySupported = capabilities.NativeMemorySupported, + gcSupported = capabilities.GcSupported, + cpuPercentSupported = capabilities.CpuPercentSupported, + fpsSupported = capabilities.FpsSupported, + frameTimingsEstimated = capabilities.FrameTimingsEstimated, + nativeFrameTimingsSupported = capabilities.NativeFrameTimingsSupported, + jankEventsSupported = capabilities.JankEventsSupported, + uiThreadStallSupported = capabilities.UiThreadStallSupported, + threadCountSupported = capabilities.ThreadCountSupported + }; + } + + private Task HandleProfilerCapabilities(HttpRequest request) + => Task.FromResult(HttpResponse.Json(BuildProfilerCapabilitiesPayload())); + + private async Task HandleProfilerStart(HttpRequest request) + { + if (!_options.EnableProfiler) + return HttpResponse.Error("Profiler is disabled. Set AgentOptions.EnableProfiler=true"); + + var body = request.BodyAs(); + var intervalMs = body?.SampleIntervalMs ?? _options.ProfilerSampleIntervalMs; + if (intervalMs < 50 || intervalMs > 60_000) + return HttpResponse.Error("sampleIntervalMs must be between 50 and 60000"); + + var session = await StartProfilerAsync(intervalMs); + return HttpResponse.Json(new { session, capabilities = BuildProfilerCapabilitiesPayload() }); + } + + private async Task HandleProfilerStop(HttpRequest request) + { + var session = await StopProfilerAsync(); + return HttpResponse.Json(new { session, stoppedAtUtc = DateTime.UtcNow }); + } + + private Task HandleProfilerSamples(HttpRequest request) + { + if (!long.TryParse(request.QueryParams.GetValueOrDefault("sampleCursor", "0"), out var sampleCursor)) + sampleCursor = 0; + if (!long.TryParse(request.QueryParams.GetValueOrDefault("markerCursor", "0"), out var markerCursor)) + markerCursor = 0; + if (!long.TryParse(request.QueryParams.GetValueOrDefault("spanCursor", "0"), out var spanCursor)) + spanCursor = 0; + if (!int.TryParse(request.QueryParams.GetValueOrDefault("limit", "500"), out var limit)) + limit = 500; + + limit = Math.Clamp(limit, 1, 5000); + var batch = _profilerSessions.GetBatch(sampleCursor, markerCursor, limit, spanCursor); + return Task.FromResult(HttpResponse.Json(batch)); + } + + private Task HandleProfilerMarker(HttpRequest request) + { + if (!IsProfilerFeatureAvailable) + return Task.FromResult(HttpResponse.Error("Profiler is not available")); + if (!_profilerSessions.IsActive) + return Task.FromResult(HttpResponse.Error("No active profiler session")); + + var body = request.BodyAs(); + if (string.IsNullOrWhiteSpace(body?.Name)) + return Task.FromResult(HttpResponse.Error("name is required")); + + var marker = new ProfilerMarker + { + TsUtc = DateTime.UtcNow, + Type = string.IsNullOrWhiteSpace(body.Type) ? "user.action" : body.Type!, + Name = body.Name!, + PayloadJson = body.PayloadJson + }; + + Publish(marker); + return Task.FromResult(HttpResponse.Ok("Marker published")); + } + + private Task HandleProfilerSpan(HttpRequest request) + { + if (!IsProfilerFeatureAvailable) + return Task.FromResult(HttpResponse.Error("Profiler is not available")); + if (!_profilerSessions.IsActive) + return Task.FromResult(HttpResponse.Error("No active profiler session")); + + var body = request.BodyAs(); + if (string.IsNullOrWhiteSpace(body?.Name)) + return Task.FromResult(HttpResponse.Error("name is required")); + + var startTsUtc = body.StartTsUtc?.ToUniversalTime() ?? DateTime.UtcNow; + var endTsUtc = body.EndTsUtc?.ToUniversalTime() ?? startTsUtc; + + var span = new ProfilerSpan + { + SpanId = Guid.NewGuid().ToString("N"), + ParentSpanId = body.ParentSpanId, + TraceId = body.TraceId, + StartTsUtc = startTsUtc, + EndTsUtc = endTsUtc, + Kind = string.IsNullOrWhiteSpace(body.Kind) ? "ui.operation" : body.Kind!, + Name = body.Name!, + Status = string.IsNullOrWhiteSpace(body.Status) ? "ok" : body.Status!, + ThreadId = body.ThreadId, + Screen = body.Screen, + ElementPath = body.ElementPath, + TagsJson = body.TagsJson, + Error = body.Error + }; + + Publish(span); + return Task.FromResult(HttpResponse.Ok("Span published")); + } + + private Task HandleProfilerHotspots(HttpRequest request) + { + if (!int.TryParse(request.QueryParams.GetValueOrDefault("limit", "20"), out var limit)) + limit = 20; + if (!int.TryParse(request.QueryParams.GetValueOrDefault("minDurationMs", "16"), out var minDurationMs)) + minDurationMs = 16; + + limit = Math.Clamp(limit, 1, 200); + minDurationMs = Math.Clamp(minDurationMs, 0, 60_000); + var kind = request.QueryParams.GetValueOrDefault("kind"); + var hotspots = _profilerSessions.GetHotspots(limit, minDurationMs, kind); + return Task.FromResult(HttpResponse.Json(hotspots)); + } + + private async Task StartProfilerAsync(int intervalMs) + { + await _profilerStateGate.WaitAsync(); + try + { + var current = _profilerSessions.CurrentSession; + if (current?.IsActive == true) + return current; + + _profilerCollector.Start(intervalMs); + var session = _profilerSessions.Start(intervalMs); + _lastAutoJankSpanTsUtc = DateTime.MinValue; + EnsureAutoUiHooks(); + _profilerLoopCts = new CancellationTokenSource(); + _profilerLoopTask = Task.Run(() => RunProfilerLoopAsync(intervalMs, _profilerLoopCts.Token)); + return session; + } + finally + { + _profilerStateGate.Release(); + } + } + + private async Task StopProfilerAsync() + { + await _profilerStateGate.WaitAsync(); + try + { + var cts = _profilerLoopCts; + var loopTask = _profilerLoopTask; + _profilerLoopCts = null; + _profilerLoopTask = null; + + cts?.Cancel(); + + if (loopTask != null) + { + try + { + await loopTask; + } + catch (OperationCanceledException) + { + } + } + + cts?.Dispose(); + _profilerCollector.Stop(); + StopAutoUiHooks(); + return _profilerSessions.Stop(); + } + finally + { + _profilerStateGate.Release(); + } + } + + private async Task RunProfilerLoopAsync(int intervalMs, CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + EnsureAutoUiHooks(); + if (_profilerCollector.TryCollect(out var sample)) + { + _profilerSessions.AddSample(sample); + PublishNativeFrameSignals(sample); + TryPublishAutoJankSpan(sample); + } + + await Task.Delay(intervalMs, ct); + } + } + + private void PublishNativeFrameSignals(ProfilerSample sample) + { + if (sample.JankFrameCount <= 0 && sample.UiThreadStallCount <= 0) + return; + + if (sample.JankFrameCount > 0) + { + Publish(new ProfilerMarker + { + TsUtc = sample.TsUtc, + Type = "ui.frame.jank.native", + Name = sample.FrameSource, + PayloadJson = JsonSerializer.Serialize(new + { + jankFrames = sample.JankFrameCount, + frameTimeMsP95 = sample.FrameTimeMsP95, + worstFrameTimeMs = sample.WorstFrameTimeMs, + frameSource = sample.FrameSource, + frameQuality = sample.FrameQuality + }) + }); + } + + if (sample.UiThreadStallCount > 0) + { + Publish(new ProfilerMarker + { + TsUtc = sample.TsUtc, + Type = "ui.thread.stall.native", + Name = sample.FrameSource, + PayloadJson = JsonSerializer.Serialize(new + { + stallCount = sample.UiThreadStallCount, + worstFrameTimeMs = sample.WorstFrameTimeMs, + frameSource = sample.FrameSource, + frameQuality = sample.FrameQuality + }) + }); + } + } + + private void TryPublishAutoJankSpan(ProfilerSample sample) + { + var frameMs = sample.FrameTimeMsP95; + var hasNativeJankSignal = sample.JankFrameCount > 0 || sample.UiThreadStallCount > 0; + if (!frameMs.HasValue || (frameMs.Value < 20d && !hasNativeJankSignal)) + return; + + var throttleMs = sample.FrameSource.StartsWith("native.", StringComparison.OrdinalIgnoreCase) ? 100d : 250d; + if (_lastAutoJankSpanTsUtc != DateTime.MinValue + && (sample.TsUtc - _lastAutoJankSpanTsUtc).TotalMilliseconds < throttleMs) + return; + + _lastAutoJankSpanTsUtc = sample.TsUtc; + var (actionName, actionElementPath, actionLagMs) = GetRecentUserAction(sample.TsUtc, TimeSpan.FromSeconds(3)); + var isStall = sample.UiThreadStallCount > 0 || (sample.WorstFrameTimeMs ?? 0d) >= 150d; + Publish(new ProfilerSpan + { + SpanId = Guid.NewGuid().ToString("N"), + TraceId = _profilerSessions.CurrentSession?.SessionId, + StartTsUtc = sample.TsUtc.AddMilliseconds(-frameMs.Value), + EndTsUtc = sample.TsUtc, + Kind = "ui.operation", + Name = isStall + ? (string.IsNullOrWhiteSpace(actionName) ? "ui.thread.stall" : "ui.action.stall") + : (string.IsNullOrWhiteSpace(actionName) ? "ui.frame.jank" : "ui.action.jank"), + Status = isStall ? "error" : "ok", + ThreadId = Environment.CurrentManagedThreadId, + Screen = Shell.Current?.CurrentState?.Location?.ToString(), + ElementPath = actionElementPath, + TagsJson = JsonSerializer.Serialize(new + { + frameTimeMsP95 = frameMs.Value, + fps = sample.Fps, + frameSource = sample.FrameSource, + frameQuality = sample.FrameQuality, + jankFrameCount = sample.JankFrameCount, + uiThreadStallCount = sample.UiThreadStallCount, + worstFrameTimeMs = sample.WorstFrameTimeMs, + actionName, + actionLagMs + }) + }); + } + + private void EnsureAutoUiHooks() + { + if (!IsProfilerFeatureAvailable || !_profilerSessions.IsActive || _dispatcher == null || !_options.EnableHighLevelUiHooks) + return; + + var now = DateTime.UtcNow; + if ((now - _lastUiHookScanTsUtc).TotalMilliseconds < UiHookScanIntervalMs) + return; + if (Interlocked.CompareExchange(ref _uiHookScanInFlight, 1, 0) != 0) + return; + + _lastUiHookScanTsUtc = now; + + void Scan() + { + try + { + TryEnsureShellNavigationHooks(); + ScanUiTreeForHooks(); + } + catch (Exception ex) + { + Publish(new ProfilerMarker + { + TsUtc = DateTime.UtcNow, + Type = "profiler.hook.error", + Name = "ui-hook-scan", + PayloadJson = JsonSerializer.Serialize(new { error = ex.GetBaseException().Message }) + }); + } + finally + { + Interlocked.Exchange(ref _uiHookScanInFlight, 0); + } + } + + if (_dispatcher.IsDispatchRequired) + { + _dispatcher.Dispatch(Scan); + } + else + { + Scan(); + } + } + + private void StopAutoUiHooks() + { + if (_hookedShell != null) + { + _hookedShell.Navigating -= OnShellNavigating; + _hookedShell.Navigated -= OnShellNavigated; + _hookedShell = null; + } + + lock (_uiHookGate) + { + foreach (var unsubscribe in _uiHookUnsubscribers) + unsubscribe(); + _uiHookUnsubscribers.Clear(); + _uiHookGeneration = _uiHookGeneration == int.MaxValue ? 1 : _uiHookGeneration + 1; + _navigationStartedAtUtc = null; + _navigationTargetRoute = null; + _lastUserActionTsUtc = DateTime.MinValue; + _lastUserActionName = null; + _lastUserActionElementPath = null; + } + } + + private void TryEnsureShellNavigationHooks() + { + var shell = Shell.Current; + if (shell == null || ReferenceEquals(shell, _hookedShell)) + return; + + if (_hookedShell != null) + { + _hookedShell.Navigating -= OnShellNavigating; + _hookedShell.Navigated -= OnShellNavigated; + } + + shell.Navigating += OnShellNavigating; + shell.Navigated += OnShellNavigated; + _hookedShell = shell; + } + + private void ScanUiTreeForHooks() + { + if (_app is not IVisualTreeElement appElement) + return; + + foreach (var child in appElement.GetVisualChildren()) + { + if (child is Element element) + ScanElementForHooks(element); + } + } + + private void ScanElementForHooks(Element element) + { + var detailedHooksEnabled = _options.EnableDetailedUiHooks; + switch (element) + { + case Button button when detailedHooksEnabled: + AttachButtonHook(button); + break; + case ImageButton imageButton when detailedHooksEnabled: + AttachImageButtonHook(imageButton); + break; + case Entry entry when detailedHooksEnabled: + AttachEntryHook(entry); + break; + case SearchBar searchBar when detailedHooksEnabled: + AttachSearchBarHook(searchBar); + break; + case CheckBox checkBox when detailedHooksEnabled: + AttachCheckBoxHook(checkBox); + break; + case Switch toggle when detailedHooksEnabled: + AttachSwitchHook(toggle); + break; + case Picker picker when detailedHooksEnabled: + AttachPickerHook(picker); + break; + case ScrollView scrollView: + AttachScrollViewHook(scrollView); + break; + case CollectionView collectionView: + AttachCollectionViewHook(collectionView); + break; + case Page page: + AttachPageHooks(page); + break; + } + + if (detailedHooksEnabled && element is View view) + { + foreach (var tapGesture in view.GestureRecognizers.OfType()) + AttachTapGestureHook(tapGesture); + } + + if (element is not IVisualTreeElement visualElement) + return; + + foreach (var child in visualElement.GetVisualChildren()) + { + if (child is Element childElement) + ScanElementForHooks(childElement); + } + } + + private bool TryRegisterUiHook(BindableObject target, string hookKey, Action? unsubscribe = null) + { + lock (_uiHookGate) + { + var state = _uiHookStates.GetOrCreateValue(target); + if (state.Generation != _uiHookGeneration) + { + state.Generation = _uiHookGeneration; + state.HookKeys.Clear(); + } + + if (!state.HookKeys.Add(hookKey)) + return false; + + if (unsubscribe != null) + _uiHookUnsubscribers.Add(unsubscribe); + + return true; + } + } + + private void AttachButtonHook(Button button) + { + if (!TryRegisterUiHook(button, "Button.Clicked", () => button.Clicked -= OnButtonClicked)) + return; + button.Clicked += OnButtonClicked; + } + + private void AttachImageButtonHook(ImageButton imageButton) + { + if (!TryRegisterUiHook(imageButton, "ImageButton.Clicked", () => imageButton.Clicked -= OnImageButtonClicked)) + return; + imageButton.Clicked += OnImageButtonClicked; + } + + private void AttachEntryHook(Entry entry) + { + if (!TryRegisterUiHook(entry, "Entry.Completed", () => entry.Completed -= OnEntryCompleted)) + return; + entry.Completed += OnEntryCompleted; + } + + private void AttachSearchBarHook(SearchBar searchBar) + { + if (!TryRegisterUiHook(searchBar, "SearchBar.SearchButtonPressed", () => searchBar.SearchButtonPressed -= OnSearchBarSearchButtonPressed)) + return; + searchBar.SearchButtonPressed += OnSearchBarSearchButtonPressed; + } + + private void AttachCheckBoxHook(CheckBox checkBox) + { + if (!TryRegisterUiHook(checkBox, "CheckBox.CheckedChanged", () => checkBox.CheckedChanged -= OnCheckBoxCheckedChanged)) + return; + checkBox.CheckedChanged += OnCheckBoxCheckedChanged; + } + + private void AttachSwitchHook(Switch toggle) + { + if (!TryRegisterUiHook(toggle, "Switch.Toggled", () => toggle.Toggled -= OnSwitchToggled)) + return; + toggle.Toggled += OnSwitchToggled; + } + + private void AttachPickerHook(Picker picker) + { + if (!TryRegisterUiHook(picker, "Picker.SelectedIndexChanged", () => picker.SelectedIndexChanged -= OnPickerSelectedIndexChanged)) + return; + picker.SelectedIndexChanged += OnPickerSelectedIndexChanged; + } + + private void AttachCollectionViewHook(CollectionView collectionView) + { + if (!TryRegisterUiHook(collectionView, "CollectionView.SelectionChanged", () => collectionView.SelectionChanged -= OnCollectionViewSelectionChanged)) + return; + collectionView.SelectionChanged += OnCollectionViewSelectionChanged; + if (TryRegisterUiHook(collectionView, "CollectionView.Scrolled", () => collectionView.Scrolled -= OnCollectionViewScrolled)) + collectionView.Scrolled += OnCollectionViewScrolled; + AttachRenderHooks(collectionView, "collection"); + } + + private void AttachScrollViewHook(ScrollView scrollView) + { + if (TryRegisterUiHook(scrollView, "ScrollView.Scrolled", () => scrollView.Scrolled -= OnScrollViewScrolled)) + scrollView.Scrolled += OnScrollViewScrolled; + AttachRenderHooks(scrollView, "scroll"); + } + + private void AttachRenderHooks(VisualElement element, string role) + { + var renderState = _elementRenderStates.GetOrCreateValue(element); + if (renderState.TrackingStartedAtUtc == default) + renderState.TrackingStartedAtUtc = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(renderState.Role)) + renderState.Role = role; + + if (TryRegisterUiHook(element, $"{role}.SizeChanged", () => element.SizeChanged -= OnTrackedElementSizeChanged)) + element.SizeChanged += OnTrackedElementSizeChanged; + if (TryRegisterUiHook(element, $"{role}.MeasureInvalidated", () => element.MeasureInvalidated -= OnTrackedElementMeasureInvalidated)) + element.MeasureInvalidated += OnTrackedElementMeasureInvalidated; + } + + private void AttachTapGestureHook(TapGestureRecognizer tapGesture) + { + if (!TryRegisterUiHook(tapGesture, "TapGestureRecognizer.Tapped", () => tapGesture.Tapped -= OnTapGestureTapped)) + return; + tapGesture.Tapped += OnTapGestureTapped; + } + + private void AttachPageHooks(Page page) + { + if (TryRegisterUiHook(page, "Page.Appearing", () => page.Appearing -= OnPageAppearing)) + page.Appearing += OnPageAppearing; + if (TryRegisterUiHook(page, "Page.Disappearing", () => page.Disappearing -= OnPageDisappearing)) + page.Disappearing += OnPageDisappearing; + if (TryRegisterUiHook(page, "Page.SizeChanged", () => page.SizeChanged -= OnPageSizeChanged)) + page.SizeChanged += OnPageSizeChanged; + if (TryRegisterUiHook(page, "Page.MeasureInvalidated", () => page.MeasureInvalidated -= OnPageMeasureInvalidated)) + page.MeasureInvalidated += OnPageMeasureInvalidated; + AttachRenderHooks(page, "page"); + } + + private void OnButtonClicked(object? sender, EventArgs args) + => TrackUiInteraction("ui.input.button.click", sender as Element); + + private void OnImageButtonClicked(object? sender, EventArgs args) + => TrackUiInteraction("ui.input.image-button.click", sender as Element); + + private void OnEntryCompleted(object? sender, EventArgs args) + => TrackUiInteraction("ui.input.entry.complete", sender as Element); + + private void OnSearchBarSearchButtonPressed(object? sender, EventArgs args) + => TrackUiInteraction("ui.input.search.submit", sender as Element); + + private void OnCheckBoxCheckedChanged(object? sender, CheckedChangedEventArgs args) + => TrackUiInteraction("ui.input.checkbox.toggle", sender as Element, new { value = args.Value }); + + private void OnSwitchToggled(object? sender, ToggledEventArgs args) + => TrackUiInteraction("ui.input.switch.toggle", sender as Element, new { value = args.Value }); + + private void OnPickerSelectedIndexChanged(object? sender, EventArgs args) + { + var picker = sender as Picker; + TrackUiInteraction("ui.input.picker.select", picker, new { selectedIndex = picker?.SelectedIndex }); + } + + private void OnCollectionViewSelectionChanged(object? sender, SelectionChangedEventArgs args) + { + var selectionCount = args.CurrentSelection?.Count ?? 0; + TrackUiInteraction("ui.input.collection.select", sender as Element, new { selectionCount }); + } + + private void OnCollectionViewScrolled(object? sender, ItemsViewScrolledEventArgs args) + { + if (sender is not CollectionView collectionView) + return; + + var horizontalOffset = TryReadDoubleProperty(args, "HorizontalOffset"); + var verticalOffset = TryReadDoubleProperty(args, "VerticalOffset"); + var firstVisibleItem = TryReadIntProperty(args, "FirstVisibleItemIndex"); + var lastVisibleItem = TryReadIntProperty(args, "LastVisibleItemIndex"); + + TrackScrollEvent( + collectionView, + sourceName: "collection-view", + offsetX: horizontalOffset, + offsetY: verticalOffset, + firstVisibleIndex: firstVisibleItem, + lastVisibleIndex: lastVisibleItem); + } + + private void OnScrollViewScrolled(object? sender, ScrolledEventArgs args) + { + if (sender is not ScrollView scrollView) + return; + + TrackScrollEvent( + scrollView, + sourceName: "scroll-view", + offsetX: args.ScrollX, + offsetY: args.ScrollY); + } + + private void TrackScrollEvent( + BindableObject source, + string sourceName, + double offsetX, + double offsetY, + int? firstVisibleIndex = null, + int? lastVisibleIndex = null) + { + if (!IsProfilerFeatureAvailable || !_profilerSessions.IsActive) + return; + + var now = DateTime.UtcNow; + var state = _scrollBatchStates.GetOrCreateValue(source); + var elementPath = BuildElementPath(source as Element); + + if (!state.IsActive) + { + state.IsActive = true; + state.StartedAtUtc = now; + state.StartOffsetX = offsetX; + state.StartOffsetY = offsetY; + state.EventCount = 0; + state.StartFirstVisibleIndex = firstVisibleIndex; + state.StartLastVisibleIndex = lastVisibleIndex; + RememberUserAction("ui.scroll", elementPath, now); + Publish(new ProfilerMarker + { + TsUtc = now, + Type = "ui.scroll.start", + Name = sourceName, + PayloadJson = JsonSerializer.Serialize(new + { + source = sourceName, + elementPath, + offsetX, + offsetY, + firstVisibleIndex, + lastVisibleIndex + }) + }); + } + + state.EventCount++; + state.LastEventAtUtc = now; + state.LastOffsetX = offsetX; + state.LastOffsetY = offsetY; + state.LastFirstVisibleIndex = firstVisibleIndex; + state.LastLastVisibleIndex = lastVisibleIndex; + var flushVersion = ++state.FlushVersion; + + if (_dispatcher != null) + { + _dispatcher.DispatchDelayed( + TimeSpan.FromMilliseconds(220), + () => TryFlushScrollBatch(source, sourceName, state, flushVersion)); + } + else + { + _ = Task.Run(async () => + { + await Task.Delay(220); + TryFlushScrollBatch(source, sourceName, state, flushVersion); + }); + } + } + + private void TryFlushScrollBatch(BindableObject source, string sourceName, ScrollBatchState state, int flushVersion) + { + if (!state.IsActive || flushVersion != state.FlushVersion) + return; + if ((DateTime.UtcNow - state.LastEventAtUtc).TotalMilliseconds < 180) + return; + + state.IsActive = false; + var startTsUtc = state.StartedAtUtc; + var endTsUtc = state.LastEventAtUtc; + if (startTsUtc == default || endTsUtc < startTsUtc) + return; + + var deltaX = state.LastOffsetX - state.StartOffsetX; + var deltaY = state.LastOffsetY - state.StartOffsetY; + var visibleShift = ComputeVisibleShift(state); + var elementPath = BuildElementPath(source as Element); + + Publish(new ProfilerMarker + { + TsUtc = endTsUtc, + Type = "ui.scroll.end", + Name = sourceName, + PayloadJson = JsonSerializer.Serialize(new + { + source = sourceName, + elementPath, + deltaX, + deltaY, + visibleShift, + events = state.EventCount + }) + }); + + Publish(new ProfilerSpan + { + SpanId = Guid.NewGuid().ToString("N"), + TraceId = _profilerSessions.CurrentSession?.SessionId, + StartTsUtc = startTsUtc, + EndTsUtc = endTsUtc, + Kind = "ui.scroll", + Name = "ui.scroll.batch", + Status = "ok", + ThreadId = Environment.CurrentManagedThreadId, + Screen = Shell.Current?.CurrentState?.Location?.ToString(), + ElementPath = elementPath, + TagsJson = JsonSerializer.Serialize(new + { + source = sourceName, + events = state.EventCount, + startOffsetX = state.StartOffsetX, + startOffsetY = state.StartOffsetY, + endOffsetX = state.LastOffsetX, + endOffsetY = state.LastOffsetY, + deltaX, + deltaY, + startFirstVisibleIndex = state.StartFirstVisibleIndex, + startLastVisibleIndex = state.StartLastVisibleIndex, + endFirstVisibleIndex = state.LastFirstVisibleIndex, + endLastVisibleIndex = state.LastLastVisibleIndex, + visibleShift + }) + }); + } + + private static int? ComputeVisibleShift(ScrollBatchState state) + { + if (!state.StartFirstVisibleIndex.HasValue || !state.LastFirstVisibleIndex.HasValue) + return null; + + return Math.Abs(state.LastFirstVisibleIndex.Value - state.StartFirstVisibleIndex.Value); + } + + private void OnTrackedElementMeasureInvalidated(object? sender, EventArgs args) + { + if (sender is not VisualElement element) + return; + + var state = _elementRenderStates.GetOrCreateValue(element); + state.MeasureInvalidatedCount++; + } + + private void OnTrackedElementSizeChanged(object? sender, EventArgs args) + { + if (sender is not VisualElement element) + return; + + var state = _elementRenderStates.GetOrCreateValue(element); + state.SizeChangedCount++; + if (state.FirstLayoutPublished || element.Width <= 0 || element.Height <= 0) + return; + + if (state.TrackingStartedAtUtc == default) + state.TrackingStartedAtUtc = DateTime.UtcNow; + + state.FirstLayoutPublished = true; + PublishUiOperationSpan( + "ui.render.first-layout", + state.TrackingStartedAtUtc, + true, + null, + BuildElementPath(element), + new + { + role = state.Role, + viewType = element.GetType().Name, + width = element.Width, + height = element.Height, + sizeChangedCount = state.SizeChangedCount, + measureInvalidatedCount = state.MeasureInvalidatedCount + }); + } + + private void OnTapGestureTapped(object? sender, TappedEventArgs args) + { + var parameter = args.Parameter?.ToString(); + TrackUiInteraction("ui.input.tap-gesture", sender as Element, new { parameter }); + } + + private void OnPageAppearing(object? sender, EventArgs args) + { + if (sender is not Page page) + return; + + var now = DateTime.UtcNow; + var route = Shell.Current?.CurrentState?.Location?.ToString(); + var state = _pageLifecycleStates.GetOrCreateValue(page); + state.AppearingAtUtc = now; + state.Route = route; + state.FirstLayoutPublished = false; + state.SizeChangedCount = 0; + state.MeasureInvalidatedCount = 0; + + TrackUiInteraction("ui.page.appearing", page, new { route, page = page.GetType().Name }); + TryPublishNavigationToAppearing(page, route); + } + + private void OnPageDisappearing(object? sender, EventArgs args) + { + if (sender is not Page page) + return; + + var route = Shell.Current?.CurrentState?.Location?.ToString(); + TrackUiInteraction("ui.page.disappearing", page, new { route, page = page.GetType().Name }); + } + + private void OnPageMeasureInvalidated(object? sender, EventArgs args) + { + if (sender is not Page page) + return; + + var state = _pageLifecycleStates.GetOrCreateValue(page); + state.MeasureInvalidatedCount++; + } + + private void OnPageSizeChanged(object? sender, EventArgs args) + { + if (sender is not Page page) + return; + + var now = DateTime.UtcNow; + var state = _pageLifecycleStates.GetOrCreateValue(page); + state.SizeChangedCount++; + if (state.FirstLayoutPublished || page.Width <= 0 || page.Height <= 0) + return; + + state.FirstLayoutPublished = true; + var startTsUtc = state.AppearingAtUtc == default ? now : state.AppearingAtUtc; + var route = state.Route ?? Shell.Current?.CurrentState?.Location?.ToString(); + + PublishUiOperationSpan( + "ui.page.first-layout", + startTsUtc, + true, + null, + BuildElementPath(page), + new + { + route, + page = page.GetType().Name, + width = page.Width, + height = page.Height, + sizeChangedCount = state.SizeChangedCount, + measureInvalidatedCount = state.MeasureInvalidatedCount + }); + + TryPublishNavigationToFirstLayout(page, route); + } + + private void OnShellNavigating(object? sender, ShellNavigatingEventArgs args) + { + var startedAtUtc = DateTime.UtcNow; + var targetRoute = TryReadNavigationRoute(args, "Target") + ?? Shell.Current?.CurrentState?.Location?.ToString() + ?? "unknown"; + + lock (_uiHookGate) + { + _navigationStartedAtUtc = startedAtUtc; + _navigationTargetRoute = targetRoute; + } + RememberUserAction("navigation.start", targetRoute, startedAtUtc); + + Publish(new ProfilerMarker + { + TsUtc = startedAtUtc, + Type = "navigation.start", + Name = targetRoute, + PayloadJson = JsonSerializer.Serialize(new { route = targetRoute }) + }); + } + + private void OnShellNavigated(object? sender, ShellNavigatedEventArgs args) + { + var endedAtUtc = DateTime.UtcNow; + DateTime startedAtUtc; + string route; + + lock (_uiHookGate) + { + startedAtUtc = _navigationStartedAtUtc ?? endedAtUtc; + route = _navigationTargetRoute + ?? TryReadNavigationRoute(args, "Current") + ?? Shell.Current?.CurrentState?.Location?.ToString() + ?? "unknown"; + } + + var source = TryReadNavigationSource(args) ?? "unknown"; + var currentPage = Shell.Current?.CurrentPage?.GetType().Name; + + Publish(new ProfilerMarker + { + TsUtc = endedAtUtc, + Type = "navigation.end", + Name = route, + PayloadJson = JsonSerializer.Serialize(new { route, source, page = currentPage }) + }); + RememberUserAction("navigation.route", route, endedAtUtc); + + PublishUiOperationSpan( + "navigation.shell.completed", + startedAtUtc, + true, + null, + route, + new { route, source, page = currentPage }); + } + + private void TryPublishNavigationToAppearing(Page page, string? route) + { + DateTime? navigationStartedAtUtc; + string? navigationRoute; + lock (_uiHookGate) + { + navigationStartedAtUtc = _navigationStartedAtUtc; + navigationRoute = _navigationTargetRoute; + } + + if (!navigationStartedAtUtc.HasValue) + return; + + PublishUiOperationSpan( + "navigation.to-page-appearing", + navigationStartedAtUtc.Value, + true, + null, + BuildElementPath(page), + new + { + targetRoute = navigationRoute, + currentRoute = route, + page = page.GetType().Name + }); + } + + private void TryPublishNavigationToFirstLayout(Page page, string? route) + { + DateTime? navigationStartedAtUtc; + string? navigationRoute; + lock (_uiHookGate) + { + navigationStartedAtUtc = _navigationStartedAtUtc; + navigationRoute = _navigationTargetRoute; + _navigationStartedAtUtc = null; + _navigationTargetRoute = null; + } + + if (!navigationStartedAtUtc.HasValue) + return; + + PublishUiOperationSpan( + "navigation.to-first-layout", + navigationStartedAtUtc.Value, + true, + null, + BuildElementPath(page), + new + { + targetRoute = navigationRoute, + currentRoute = route, + page = page.GetType().Name + }); + } + + private void TrackUiInteraction(string name, Element? element, object? tags = null) + { + if (!IsProfilerFeatureAvailable || !_profilerSessions.IsActive) + return; + + var startedAtUtc = DateTime.UtcNow; + var elementPath = BuildElementPath(element); + var markerPayload = JsonSerializer.Serialize(new + { + name, + elementPath, + tags + }); + + Publish(new ProfilerMarker + { + TsUtc = startedAtUtc, + Type = "user.action", + Name = name, + PayloadJson = markerPayload + }); + + RememberUserAction(name, elementPath, startedAtUtc); + + if (_dispatcher != null) + { + _dispatcher.DispatchDelayed( + TimeSpan.FromMilliseconds(1), + () => PublishUiOperationSpan(name, startedAtUtc, true, null, elementPath, tags)); + return; + } + + PublishUiOperationSpan(name, startedAtUtc, true, null, elementPath, tags); + } + + private static string? BuildElementPath(Element? element) + { + if (element == null) + return null; + + if (!string.IsNullOrWhiteSpace(element.AutomationId)) + return $"{element.GetType().Name}#{element.AutomationId}"; + if (element is Page page && !string.IsNullOrWhiteSpace(page.Title)) + return $"{page.GetType().Name}:{page.Title}"; + if (element is VisualElement visualElement && !string.IsNullOrWhiteSpace(visualElement.StyleId)) + return $"{visualElement.GetType().Name}[{visualElement.StyleId}]"; + + return element.GetType().Name; + } + + private void RememberUserAction(string name, string? elementPath, DateTime timestampUtc) + { + lock (_uiHookGate) + { + _lastUserActionTsUtc = timestampUtc; + _lastUserActionName = name; + _lastUserActionElementPath = elementPath; + } + } + + private (string? ActionName, string? ElementPath, double? LagMs) GetRecentUserAction(DateTime sampleTsUtc, TimeSpan maxAge) + { + lock (_uiHookGate) + { + if (_lastUserActionTsUtc == DateTime.MinValue || string.IsNullOrWhiteSpace(_lastUserActionName)) + return (null, null, null); + + var lag = sampleTsUtc - _lastUserActionTsUtc; + if (lag < TimeSpan.Zero || lag > maxAge) + return (null, null, null); + + return (_lastUserActionName, _lastUserActionElementPath, lag.TotalMilliseconds); + } + } + + private static double TryReadDoubleProperty(object instance, string propertyName) + { + var value = instance.GetType().GetProperty(propertyName)?.GetValue(instance); + return value switch + { + double asDouble => asDouble, + float asFloat => asFloat, + int asInt => asInt, + long asLong => asLong, + _ => 0d + }; + } + + private static int? TryReadIntProperty(object instance, string propertyName) + { + var value = instance.GetType().GetProperty(propertyName)?.GetValue(instance); + return value switch + { + int asInt => asInt, + long asLong => (int)asLong, + short asShort => asShort, + _ => null + }; + } + + private static string? TryReadNavigationRoute(object eventArgs, string statePropertyName) + { + var state = eventArgs.GetType().GetProperty(statePropertyName)?.GetValue(eventArgs); + if (state == null) + return null; + + var location = state.GetType().GetProperty("Location")?.GetValue(state); + return location?.ToString() ?? state.ToString(); + } + + private static string? TryReadNavigationSource(object eventArgs) + => eventArgs.GetType().GetProperty("Source")?.GetValue(eventArgs)?.ToString(); + + public void Publish(ProfilerMarker marker) + { + if (!IsProfilerFeatureAvailable || !_profilerSessions.IsActive) + return; + + if (marker.TsUtc == default) + marker.TsUtc = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(marker.Type)) + marker.Type = "user.action"; + if (string.IsNullOrWhiteSpace(marker.Name)) + marker.Name = marker.Type; + + _profilerSessions.AddMarker(marker); + } + + public void Publish(ProfilerSpan span) + { + if (!IsProfilerFeatureAvailable || !_profilerSessions.IsActive) + return; + + if (string.IsNullOrWhiteSpace(span.Kind)) + span.Kind = "ui.operation"; + if (string.IsNullOrWhiteSpace(span.Name)) + span.Name = span.Kind; + if (string.IsNullOrWhiteSpace(span.Status)) + span.Status = "ok"; + if (span.StartTsUtc == default) + span.StartTsUtc = DateTime.UtcNow; + if (span.EndTsUtc == default || span.EndTsUtc < span.StartTsUtc) + span.EndTsUtc = span.StartTsUtc; + if (span.ThreadId == null) + span.ThreadId = Environment.CurrentManagedThreadId; + + _profilerSessions.AddSpan(span); + } + + private void PublishUiOperationSpan( + string name, + DateTime startedAtUtc, + bool success, + string? error = null, + string? elementPath = null, + object? tags = null) + { + var endTsUtc = DateTime.UtcNow; + var route = Shell.Current?.CurrentState?.Location?.ToString(); + var span = new ProfilerSpan + { + SpanId = Guid.NewGuid().ToString("N"), + TraceId = _profilerSessions.CurrentSession?.SessionId, + StartTsUtc = startedAtUtc, + EndTsUtc = endTsUtc, + Kind = "ui.operation", + Name = name, + Status = success ? "ok" : "error", + ThreadId = Environment.CurrentManagedThreadId, + Screen = route, + ElementPath = elementPath, + TagsJson = tags == null ? null : JsonSerializer.Serialize(tags), + Error = error + }; + + Publish(span); + } + + private void HandleCapturedNetworkRequest(NetworkRequestEntry entry) + { + if (!IsProfilerFeatureAvailable || !_profilerSessions.IsActive) + return; + + var endTimestampUtc = entry.Timestamp.UtcDateTime; + var startTimestampUtc = endTimestampUtc - TimeSpan.FromMilliseconds(Math.Max(0, entry.DurationMs)); + var markerName = $"{entry.Method} {entry.Path ?? entry.Url}"; + + Publish(new ProfilerMarker + { + TsUtc = startTimestampUtc, + Type = "network.request.start", + Name = markerName, + PayloadJson = JsonSerializer.Serialize(new + { + id = entry.Id, + method = entry.Method, + url = entry.Url, + host = entry.Host + }) + }); + + Publish(new ProfilerMarker + { + TsUtc = endTimestampUtc, + Type = "network.request.end", + Name = markerName, + PayloadJson = JsonSerializer.Serialize(new + { + id = entry.Id, + method = entry.Method, + url = entry.Url, + statusCode = entry.StatusCode, + durationMs = entry.DurationMs, + error = entry.Error + }) + }); + + if (entry.DurationMs >= 50 || !string.IsNullOrWhiteSpace(entry.Error)) + { + Publish(new ProfilerSpan + { + SpanId = Guid.NewGuid().ToString("N"), + TraceId = _profilerSessions.CurrentSession?.SessionId, + StartTsUtc = startTimestampUtc, + EndTsUtc = endTimestampUtc, + Kind = "network.request", + Name = markerName, + Status = string.IsNullOrWhiteSpace(entry.Error) ? "ok" : "error", + ThreadId = Environment.CurrentManagedThreadId, + Screen = Shell.Current?.CurrentState?.Location?.ToString(), + TagsJson = JsonSerializer.Serialize(new + { + id = entry.Id, + method = entry.Method, + host = entry.Host, + statusCode = entry.StatusCode + }), + Error = entry.Error + }); + } + } + public void Dispose() { if (_disposed) return; _disposed = true; + NetworkStore.OnRequestCaptured -= HandleCapturedNetworkRequest; + StopAutoUiHooks(); + + var cts = _profilerLoopCts; + var loopTask = _profilerLoopTask; + _profilerLoopCts = null; + _profilerLoopTask = null; + + cts?.Cancel(); + if (loopTask != null) + { + try { loopTask.Wait(TimeSpan.FromSeconds(3)); } + catch (AggregateException) { } + } + cts?.Dispose(); + + _profilerCollector.Stop(); + (_profilerCollector as IDisposable)?.Dispose(); + _profilerStateGate.Dispose(); _brokerRegistration?.Dispose(); _server.Dispose(); _logProvider?.Dispose(); diff --git a/src/MauiDevFlow.Agent.Core/Profiling/IMarkerPublisher.cs b/src/MauiDevFlow.Agent.Core/Profiling/IMarkerPublisher.cs new file mode 100644 index 0000000..f09e397 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/IMarkerPublisher.cs @@ -0,0 +1,6 @@ +namespace MauiDevFlow.Agent.Core.Profiling; + +public interface IMarkerPublisher +{ + void Publish(ProfilerMarker marker); +} diff --git a/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs b/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs new file mode 100644 index 0000000..676c6e8 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs @@ -0,0 +1,24 @@ +namespace MauiDevFlow.Agent.Core.Profiling; + +public sealed class NativeFrameStatsSnapshot +{ + public DateTime TsUtc { get; set; } + public string Source { get; set; } = "native.unknown"; + public double? Fps { get; set; } + public double? FrameTimeMsP50 { get; set; } + public double? FrameTimeMsP95 { get; set; } + public double? WorstFrameTimeMs { get; set; } + public int JankFrameCount { get; set; } + public int UiThreadStallCount { get; set; } + public long? NativeMemoryBytes { get; set; } +} + +public interface INativeFrameStatsProvider : IDisposable +{ + bool IsSupported { get; } + bool ProvidesExactFrameTimings { get; } + string Source { get; } + void Start(); + void Stop(); + bool TryCollect(out NativeFrameStatsSnapshot snapshot); +} diff --git a/src/MauiDevFlow.Agent.Core/Profiling/IProfilerCollector.cs b/src/MauiDevFlow.Agent.Core/Profiling/IProfilerCollector.cs new file mode 100644 index 0000000..03e5c8f --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/IProfilerCollector.cs @@ -0,0 +1,9 @@ +namespace MauiDevFlow.Agent.Core.Profiling; + +public interface IProfilerCollector +{ + void Start(int intervalMs); + void Stop(); + bool TryCollect(out ProfilerSample sample); + ProfilerCapabilities GetCapabilities(); +} diff --git a/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs new file mode 100644 index 0000000..82a5db9 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs @@ -0,0 +1,124 @@ +namespace MauiDevFlow.Agent.Core.Profiling; + +public class ProfilerSessionInfo +{ + public string SessionId { get; set; } = ""; + public DateTime StartedAtUtc { get; set; } + public int SampleIntervalMs { get; set; } + public bool IsActive { get; set; } +} + +public class ProfilerSample +{ + public DateTime TsUtc { get; set; } + public double? Fps { get; set; } + public double? FrameTimeMsP50 { get; set; } + public double? FrameTimeMsP95 { get; set; } + public double? WorstFrameTimeMs { get; set; } + public long ManagedBytes { get; set; } + public int Gc0 { get; set; } + public int Gc1 { get; set; } + public int Gc2 { get; set; } + public long? NativeMemoryBytes { get; set; } + public double? CpuPercent { get; set; } + public int? ThreadCount { get; set; } + public int JankFrameCount { get; set; } + public int UiThreadStallCount { get; set; } + public string FrameSource { get; set; } = "managed.estimated"; + public string FrameQuality { get; set; } = "estimated"; +} + +public class ProfilerMarker +{ + public DateTime TsUtc { get; set; } + public string Type { get; set; } = ""; + public string Name { get; set; } = ""; + public string? PayloadJson { get; set; } +} + +public class ProfilerBatch +{ + public string SessionId { get; set; } = ""; + public List Samples { get; set; } = new(); + public List Markers { get; set; } = new(); + public List Spans { get; set; } = new(); + public long SampleCursor { get; set; } + public long MarkerCursor { get; set; } + public long SpanCursor { get; set; } + public bool IsActive { get; set; } +} + +public class ProfilerSpan +{ + public string SpanId { get; set; } = Guid.NewGuid().ToString("N"); + public string? ParentSpanId { get; set; } + public string? TraceId { get; set; } + public DateTime StartTsUtc { get; set; } + public DateTime EndTsUtc { get; set; } + public double DurationMs { get; set; } + public string Kind { get; set; } = "ui.operation"; + public string Name { get; set; } = ""; + public string Status { get; set; } = "ok"; + public int? ThreadId { get; set; } + public string? Screen { get; set; } + public string? ElementPath { get; set; } + public string? TagsJson { get; set; } + public string? Error { get; set; } +} + +public class ProfilerHotspot +{ + public string Kind { get; set; } = ""; + public string Name { get; set; } = ""; + public string? Screen { get; set; } + public int Count { get; set; } + public int ErrorCount { get; set; } + public double AvgDurationMs { get; set; } + public double P95DurationMs { get; set; } + public double MaxDurationMs { get; set; } +} + +public class PublishProfilerSpanRequest +{ + public string? Kind { get; set; } + public string? Name { get; set; } + public string? Status { get; set; } + public string? ParentSpanId { get; set; } + public string? TraceId { get; set; } + public DateTime? StartTsUtc { get; set; } + public DateTime? EndTsUtc { get; set; } + public int? ThreadId { get; set; } + public string? Screen { get; set; } + public string? ElementPath { get; set; } + public string? TagsJson { get; set; } + public string? Error { get; set; } +} + +public class ProfilerCapabilities +{ + public bool SupportedInBuild { get; set; } + public bool FeatureEnabled { get; set; } + public string Platform { get; set; } = "unknown"; + public bool ManagedMemorySupported { get; set; } + public bool NativeMemorySupported { get; set; } + public bool GcSupported { get; set; } + public bool CpuPercentSupported { get; set; } + public bool FpsSupported { get; set; } + public bool FrameTimingsEstimated { get; set; } + public bool NativeFrameTimingsSupported { get; set; } + public bool JankEventsSupported { get; set; } + public bool UiThreadStallSupported { get; set; } + public bool ThreadCountSupported { get; set; } +} + +public class StartProfilerRequest +{ + public int? SampleIntervalMs { get; set; } +} + +public class PublishProfilerMarkerRequest +{ + public string? Type { get; set; } + public string? Name { get; set; } + public string? PayloadJson { get; set; } +} diff --git a/src/MauiDevFlow.Agent.Core/Profiling/ProfilerRingBuffer.cs b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerRingBuffer.cs new file mode 100644 index 0000000..68a28e7 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerRingBuffer.cs @@ -0,0 +1,71 @@ +namespace MauiDevFlow.Agent.Core.Profiling; + +public class ProfilerRingBuffer where T : class +{ + private readonly (long Sequence, T Value)[] _buffer; + private long _latestSequence; + private int _count; + private readonly object _gate = new(); + + public ProfilerRingBuffer(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity), "Capacity must be > 0"); + _buffer = new (long Sequence, T Value)[capacity]; + } + + public int Capacity => _buffer.Length; + + public long Add(T value) + { + lock (_gate) + { + var next = ++_latestSequence; + var index = (int)((next - 1) % _buffer.Length); + _buffer[index] = (next, value); + if (_count < _buffer.Length) + _count++; + return next; + } + } + + public List ReadAfter(long afterSequence, int limit, out long latestSequence) + { + if (limit <= 0) + { + latestSequence = _latestSequence; + return new List(); + } + + lock (_gate) + { + latestSequence = _latestSequence; + if (_count == 0 || afterSequence >= _latestSequence) + return new List(); + + var oldestSequence = _latestSequence - _count + 1; + var firstSequence = Math.Max(afterSequence + 1, oldestSequence); + var remaining = _latestSequence - firstSequence + 1; + var take = (int)Math.Min(limit, remaining); + var results = new List(take); + + for (var sequence = firstSequence; sequence < firstSequence + take; sequence++) + { + var index = (int)((sequence - 1) % _buffer.Length); + results.Add(_buffer[index].Value); + } + + return results; + } + } + + public void Clear() + { + lock (_gate) + { + _latestSequence = 0; + _count = 0; + Array.Clear(_buffer); + } + } +} diff --git a/src/MauiDevFlow.Agent.Core/Profiling/ProfilerSessionStore.cs b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerSessionStore.cs new file mode 100644 index 0000000..3a38cc1 --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerSessionStore.cs @@ -0,0 +1,218 @@ +namespace MauiDevFlow.Agent.Core.Profiling; + +public class ProfilerSessionStore +{ + private readonly ProfilerRingBuffer _samples; + private readonly ProfilerRingBuffer _markers; + private readonly ProfilerRingBuffer _spans; + private readonly object _gate = new(); + private DateTime _lastSampleTimestampUtc = DateTime.MinValue; + private DateTime _lastMarkerTimestampUtc = DateTime.MinValue; + private DateTime _lastSpanTimestampUtc = DateTime.MinValue; + private ProfilerSessionInfo? _session; + + public ProfilerSessionStore(int maxSamples, int maxMarkers, int maxSpans) + { + _samples = new ProfilerRingBuffer(maxSamples); + _markers = new ProfilerRingBuffer(maxMarkers); + _spans = new ProfilerRingBuffer(maxSpans); + } + + public bool IsActive + { + get + { + lock (_gate) + { + return _session?.IsActive == true; + } + } + } + + public ProfilerSessionInfo? CurrentSession + { + get + { + lock (_gate) + { + return _session; + } + } + } + + public ProfilerSessionInfo Start(int sampleIntervalMs) + { + lock (_gate) + { + _samples.Clear(); + _markers.Clear(); + _spans.Clear(); + _lastSampleTimestampUtc = DateTime.MinValue; + _lastMarkerTimestampUtc = DateTime.MinValue; + _lastSpanTimestampUtc = DateTime.MinValue; + _session = new ProfilerSessionInfo + { + SessionId = Guid.NewGuid().ToString("N"), + StartedAtUtc = DateTime.UtcNow, + SampleIntervalMs = sampleIntervalMs, + IsActive = true + }; + return _session; + } + } + + public ProfilerSessionInfo? Stop() + { + lock (_gate) + { + if (_session != null) + _session.IsActive = false; + return _session; + } + } + + public void AddSample(ProfilerSample sample) + { + lock (_gate) + { + if (_session?.IsActive != true) + return; + + if (sample.TsUtc <= _lastSampleTimestampUtc) + sample.TsUtc = _lastSampleTimestampUtc.AddTicks(1); + _lastSampleTimestampUtc = sample.TsUtc; + _samples.Add(sample); + } + } + + public void AddMarker(ProfilerMarker marker) + { + lock (_gate) + { + if (_session?.IsActive != true) + return; + + if (marker.TsUtc <= _lastMarkerTimestampUtc) + marker.TsUtc = _lastMarkerTimestampUtc.AddTicks(1); + _lastMarkerTimestampUtc = marker.TsUtc; + _markers.Add(marker); + } + } + + public void AddSpan(ProfilerSpan span) + { + lock (_gate) + { + if (_session?.IsActive != true) + return; + + if (span.StartTsUtc == default) + span.StartTsUtc = DateTime.UtcNow; + + if (span.StartTsUtc <= _lastSpanTimestampUtc) + span.StartTsUtc = _lastSpanTimestampUtc.AddTicks(1); + + if (span.EndTsUtc == default || span.EndTsUtc < span.StartTsUtc) + span.EndTsUtc = span.StartTsUtc; + + if (span.SpanId.Length == 0) + span.SpanId = Guid.NewGuid().ToString("N"); + + span.DurationMs = Math.Max(0d, (span.EndTsUtc - span.StartTsUtc).TotalMilliseconds); + _lastSpanTimestampUtc = span.EndTsUtc; + _spans.Add(span); + } + } + + public List GetHotspots(int limit, int minDurationMs, string? kind = null) + { + List spans; + lock (_gate) + { + spans = _spans.ReadAfter(0, _spans.Capacity, out _); + } + + if (spans.Count == 0) + return new List(); + + var filtered = spans + .Where(span => span.DurationMs >= minDurationMs) + .Where(span => string.IsNullOrWhiteSpace(kind) || span.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase)); + + var hotspots = filtered + .GroupBy(span => new + { + span.Kind, + span.Name, + Screen = string.IsNullOrWhiteSpace(span.Screen) ? null : span.Screen + }) + .Select(group => + { + var durations = group + .Select(span => span.DurationMs) + .OrderBy(value => value) + .ToArray(); + + var count = durations.Length; + var p95Index = count <= 1 + ? 0 + : Math.Min((int)Math.Ceiling(count * 0.95), count - 1); + + return new ProfilerHotspot + { + Kind = group.Key.Kind, + Name = group.Key.Name, + Screen = group.Key.Screen, + Count = count, + ErrorCount = group.Count(span => !span.Status.Equals("ok", StringComparison.OrdinalIgnoreCase)), + AvgDurationMs = count == 0 ? 0 : durations.Average(), + P95DurationMs = count == 0 ? 0 : durations[p95Index], + MaxDurationMs = count == 0 ? 0 : durations[^1] + }; + }) + .OrderByDescending(h => h.P95DurationMs) + .ThenByDescending(h => h.MaxDurationMs) + .ThenByDescending(h => h.Count) + .Take(Math.Max(1, limit)) + .ToList(); + + return hotspots; + } + + public ProfilerBatch GetBatch(long sampleCursor, long markerCursor, int limit, long spanCursor = 0) + { + lock (_gate) + { + if (_session == null) + { + return new ProfilerBatch + { + SessionId = "", + IsActive = false, + Samples = new(), + Markers = new(), + Spans = new(), + SampleCursor = 0, + MarkerCursor = 0, + SpanCursor = 0 + }; + } + + var samples = _samples.ReadAfter(sampleCursor, limit, out var latestSampleCursor); + var markers = _markers.ReadAfter(markerCursor, limit, out var latestMarkerCursor); + var spans = _spans.ReadAfter(spanCursor, limit, out var latestSpanCursor); + + return new ProfilerBatch + { + SessionId = _session.SessionId, + IsActive = _session.IsActive, + Samples = samples, + Markers = markers, + Spans = spans, + SampleCursor = latestSampleCursor, + MarkerCursor = latestMarkerCursor, + SpanCursor = latestSpanCursor + }; + } + } +} diff --git a/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs b/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs new file mode 100644 index 0000000..854280a --- /dev/null +++ b/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs @@ -0,0 +1,354 @@ +using System.Diagnostics; +using Microsoft.Maui.Devices; + +namespace MauiDevFlow.Agent.Core.Profiling; + +public class RuntimeProfilerCollector : IProfilerCollector, IDisposable +{ + private readonly Process _process = Process.GetCurrentProcess(); + private readonly INativeFrameStatsProvider? _nativeFrameStatsProvider; + private readonly ProfilerCapabilities _capabilities; + + private bool _running; + private DateTime _lastSampleTimestampUtc; + private TimeSpan _lastCpuTime; + private int _sampleIntervalMs = 500; + private double _estimatedFrameTimeMs = 1000d / 60d; + private string _estimatedFrameQuality = "estimated.default-60hz"; + + public RuntimeProfilerCollector(INativeFrameStatsProvider? nativeFrameStatsProvider = null) + { + _nativeFrameStatsProvider = nativeFrameStatsProvider; + _capabilities = new ProfilerCapabilities + { + Platform = GetPlatformName(), + ManagedMemorySupported = true, + NativeMemorySupported = true, + GcSupported = true, + CpuPercentSupported = true, + ThreadCountSupported = true, + FpsSupported = true, + FrameTimingsEstimated = true, + NativeFrameTimingsSupported = false, + JankEventsSupported = false, + UiThreadStallSupported = false + }; + + if (_nativeFrameStatsProvider?.IsSupported == true) + { + _capabilities.NativeFrameTimingsSupported = true; + _capabilities.JankEventsSupported = true; + _capabilities.UiThreadStallSupported = true; + _capabilities.FrameTimingsEstimated = !_nativeFrameStatsProvider.ProvidesExactFrameTimings; + } + } + + public void Start(int intervalMs) + { + if (intervalMs <= 0) + throw new ArgumentOutOfRangeException(nameof(intervalMs), "Sample interval must be > 0"); + + _sampleIntervalMs = intervalMs; + _lastSampleTimestampUtc = DateTime.UtcNow; + if (_nativeFrameStatsProvider?.IsSupported == true) + { + // Native providers own frame timing. Keep a safe fallback estimate in case native collection is disabled later. + _estimatedFrameTimeMs = 1000d / 60d; + _estimatedFrameQuality = "estimated.default-60hz"; + } + else + { + (_estimatedFrameTimeMs, _estimatedFrameQuality) = ResolveFrameEstimate(); + } + + try + { + _process.Refresh(); + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + _capabilities.CpuPercentSupported = false; + _capabilities.ThreadCountSupported = false; + _capabilities.NativeMemorySupported = false; + } + + if (_capabilities.CpuPercentSupported) + { + try + { + _lastCpuTime = _process.TotalProcessorTime; + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + _capabilities.CpuPercentSupported = false; + _lastCpuTime = TimeSpan.Zero; + } + } + + if (_nativeFrameStatsProvider?.IsSupported == true) + { + try + { + _nativeFrameStatsProvider.Start(); + } + catch (Exception ex) when (IsNativeProviderAccessException(ex)) + { + TryStopNativeProviderAfterStartupFailure(); + _capabilities.NativeFrameTimingsSupported = false; + _capabilities.JankEventsSupported = false; + _capabilities.UiThreadStallSupported = false; + _capabilities.FrameTimingsEstimated = true; + } + } + + _running = true; + } + + public void Stop() + { + _running = false; + _nativeFrameStatsProvider?.Stop(); + } + + public bool TryCollect(out ProfilerSample sample) + { + sample = new ProfilerSample(); + if (!_running) + return false; + + var now = DateTime.UtcNow; + var elapsedMs = Math.Max(1d, (now - _lastSampleTimestampUtc).TotalMilliseconds); + var processSnapshotAvailable = TryRefreshProcessSnapshot(); + var cpuPercent = TryReadCpuPercent(elapsedMs, processSnapshotAvailable); + var threadCount = TryReadThreadCount(processSnapshotAvailable); + + sample = BuildFrameSample(now); + sample.ManagedBytes = GC.GetTotalMemory(false); + sample.Gc0 = GC.CollectionCount(0); + sample.Gc1 = GC.CollectionCount(1); + sample.Gc2 = GC.CollectionCount(2); + sample.NativeMemoryBytes ??= TryReadNativeMemoryBytes(processSnapshotAvailable, sample.ManagedBytes); + sample.CpuPercent = cpuPercent; + sample.ThreadCount = threadCount; + + _lastSampleTimestampUtc = now; + return true; + } + + public ProfilerCapabilities GetCapabilities() => _capabilities; + + private ProfilerSample BuildFrameSample(DateTime now) + { + if (_nativeFrameStatsProvider?.IsSupported == true + && _nativeFrameStatsProvider.TryCollect(out var nativeSnapshot)) + { + return new ProfilerSample + { + TsUtc = now, + Fps = nativeSnapshot.Fps, + FrameTimeMsP50 = nativeSnapshot.FrameTimeMsP50, + FrameTimeMsP95 = nativeSnapshot.FrameTimeMsP95, + WorstFrameTimeMs = nativeSnapshot.WorstFrameTimeMs, + JankFrameCount = nativeSnapshot.JankFrameCount, + UiThreadStallCount = nativeSnapshot.UiThreadStallCount, + NativeMemoryBytes = nativeSnapshot.NativeMemoryBytes, + FrameSource = nativeSnapshot.Source, + FrameQuality = _nativeFrameStatsProvider.ProvidesExactFrameTimings + ? "native.exact" + : "native.cadence" + }; + } + + var elapsedMs = Math.Max(1d, (now - _lastSampleTimestampUtc).TotalMilliseconds); + var effectiveElapsedMs = Math.Max(_sampleIntervalMs, elapsedMs); + var lagRatio = effectiveElapsedMs / _sampleIntervalMs; + var estimatedFrameTimeMs = _estimatedFrameTimeMs * lagRatio; + var estimatedFps = estimatedFrameTimeMs > 0 ? 1000d / estimatedFrameTimeMs : (double?)null; + + return new ProfilerSample + { + TsUtc = now, + Fps = estimatedFps, + FrameTimeMsP50 = estimatedFrameTimeMs, + FrameTimeMsP95 = estimatedFrameTimeMs, + WorstFrameTimeMs = estimatedFrameTimeMs, + JankFrameCount = estimatedFrameTimeMs >= 24d ? 1 : 0, + UiThreadStallCount = estimatedFrameTimeMs >= 150d ? 1 : 0, + FrameSource = "managed.estimated", + FrameQuality = $"{_estimatedFrameQuality}.sampling-lag" + }; + } + + private bool TryRefreshProcessSnapshot() + { + if (!_capabilities.CpuPercentSupported + && !_capabilities.ThreadCountSupported + && !_capabilities.NativeMemorySupported) + return false; + + try + { + _process.Refresh(); + return true; + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + _capabilities.CpuPercentSupported = false; + _capabilities.ThreadCountSupported = false; + _capabilities.NativeMemorySupported = false; + return false; + } + } + + private double? TryReadCpuPercent(double elapsedMs, bool processSnapshotAvailable) + { + if (!_capabilities.CpuPercentSupported || !processSnapshotAvailable) + return null; + + try + { + var cpuTime = _process.TotalProcessorTime; + var cpuDeltaMs = (cpuTime - _lastCpuTime).TotalMilliseconds; + _lastCpuTime = cpuTime; + + if (cpuDeltaMs < 0) + return null; + + var normalized = (cpuDeltaMs / (elapsedMs * Environment.ProcessorCount)) * 100d; + return Math.Round(Math.Max(0d, normalized), 2); + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + _capabilities.CpuPercentSupported = false; + return null; + } + } + + private int? TryReadThreadCount(bool processSnapshotAvailable) + { + if (!_capabilities.ThreadCountSupported || !processSnapshotAvailable) + return null; + + try + { + return _process.Threads.Count; + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + _capabilities.ThreadCountSupported = false; + return null; + } + } + + private long? TryReadNativeMemoryBytes(bool processSnapshotAvailable, long managedBytes) + { + if (!_capabilities.NativeMemorySupported || !processSnapshotAvailable) + return null; + + try + { + var workingSetBytes = _process.WorkingSet64; + if (workingSetBytes <= 0) + return null; + + return Math.Max(0L, workingSetBytes - managedBytes); + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + _capabilities.NativeMemorySupported = false; + return null; + } + } + + private static (double FrameTimeMs, string Quality) ResolveFrameEstimate() + { + const double fallbackRefreshRate = 60d; + var refreshRate = TryReadDisplayRefreshRate(); + + if (refreshRate.HasValue) + return (1000d / refreshRate.Value, "estimated.display-refresh"); + + return (1000d / fallbackRefreshRate, "estimated.default-60hz"); + } + + private static double? TryReadDisplayRefreshRate() + { + try + { + var refreshRate = DeviceDisplay.Current.MainDisplayInfo.RefreshRate; + if (double.IsNaN(refreshRate) || double.IsInfinity(refreshRate) || refreshRate <= 1d) + return null; + + return refreshRate; + } + catch (Exception ex) when (IsDisplayInfoAccessException(ex)) + { + return null; + } + } + + private static bool IsDisplayInfoAccessException(Exception ex) + { + return ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException + || ex.GetType().Name.Equals("UIKitThreadAccessException", StringComparison.Ordinal); + } + + private void TryStopNativeProviderAfterStartupFailure() + { + if (_nativeFrameStatsProvider is null) + return; + + try + { + _nativeFrameStatsProvider.Stop(); + } + catch (Exception ex) when (IsNativeProviderAccessException(ex)) + { + } + } + + private static bool IsNativeProviderAccessException(Exception ex) + { + return ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException + || ex is ObjectDisposedException; + } + + private static string GetPlatformName() + { + if (OperatingSystem.IsAndroid()) return "Android"; + if (OperatingSystem.IsIOS()) return "iOS"; + if (OperatingSystem.IsMacCatalyst()) return "MacCatalyst"; + if (OperatingSystem.IsMacOS()) return "macOS"; + if (OperatingSystem.IsWindows()) return "Windows"; + if (OperatingSystem.IsLinux()) return "Linux"; + return "Unknown"; + } + + public void Dispose() + { + Stop(); + _nativeFrameStatsProvider?.Dispose(); + } +} diff --git a/src/MauiDevFlow.Agent.Gtk/GtkAgentService.cs b/src/MauiDevFlow.Agent.Gtk/GtkAgentService.cs index 5bf8d47..654379f 100644 --- a/src/MauiDevFlow.Agent.Gtk/GtkAgentService.cs +++ b/src/MauiDevFlow.Agent.Gtk/GtkAgentService.cs @@ -1,6 +1,7 @@ using Microsoft.Maui; using Microsoft.Maui.Controls; using MauiDevFlow.Agent.Core; +using MauiDevFlow.Agent.Core.Profiling; namespace MauiDevFlow.Agent.Gtk; @@ -12,6 +13,7 @@ public class GtkAgentService : DevFlowAgentService public GtkAgentService(AgentOptions? options = null) : base(options) { } protected override VisualTreeWalker CreateTreeWalker() => new GtkVisualTreeWalker(); + protected override IProfilerCollector CreateProfilerCollector() => new RuntimeProfilerCollector(); protected override string PlatformName => "Linux"; protected override string DeviceTypeName => "Virtual"; diff --git a/src/MauiDevFlow.Agent/DevFlowAgentService.cs b/src/MauiDevFlow.Agent/DevFlowAgentService.cs index c991b0b..93e2014 100644 --- a/src/MauiDevFlow.Agent/DevFlowAgentService.cs +++ b/src/MauiDevFlow.Agent/DevFlowAgentService.cs @@ -1,5 +1,7 @@ using Microsoft.Maui.Controls; using MauiDevFlow.Agent.Core; +using MauiDevFlow.Agent.Core.Profiling; +using MauiDevFlow.Agent.Profiling; #if MACOS using AppKit; using Foundation; @@ -210,6 +212,14 @@ protected override Task TryNativeScroll(VisualElement element, double delt } #endif + protected override IProfilerCollector CreateProfilerCollector() + { +#if ANDROID || IOS || WINDOWS || MACCATALYST + return new RuntimeProfilerCollector(NativeFrameStatsProviderFactory.Create()); +#else + return base.CreateProfilerCollector(); +#endif + } protected override bool TryNativeTap(VisualElement ve) { try diff --git a/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs b/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs new file mode 100644 index 0000000..488d491 --- /dev/null +++ b/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs @@ -0,0 +1,579 @@ +using Microsoft.Maui.ApplicationModel; +using MauiDevFlow.Agent.Core.Profiling; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +#if ANDROID +using Android.OS; +using Android.Views; +using Microsoft.Maui.Devices; +#endif +#if IOS || MACCATALYST +using CoreAnimation; +using Foundation; +using Microsoft.Maui.Devices; +using System.Runtime.InteropServices; +#endif +#if WINDOWS +using Microsoft.Maui.Devices; +using Microsoft.UI.Xaml.Media; +#endif + +namespace MauiDevFlow.Agent.Profiling; + +internal static class NativeFrameStatsProviderFactory +{ + public static INativeFrameStatsProvider? Create() + { +#if ANDROID + return AndroidFrameMetricsStatsProvider.IsApiSupported + ? new AndroidFrameMetricsStatsProvider() + : new AndroidChoreographerFrameStatsProvider(); +#elif IOS || MACCATALYST + return new AppleDisplayLinkFrameStatsProvider(); +#elif WINDOWS + return new WindowsCompositionFrameStatsProvider(); +#else + return null; +#endif + } +} + +internal sealed class FrameStatsAccumulator +{ + private readonly object _gate = new(); + private readonly Queue _durationsMs = new(); + private readonly double _jankThresholdMs; + private readonly double _stallThresholdMs; + private readonly int _maxBufferedFrames; + + public FrameStatsAccumulator(double frameBudgetMs, int maxBufferedFrames = 720) + { + _jankThresholdMs = Math.Max(16d, frameBudgetMs * 1.5d); + _stallThresholdMs = 150d; + _maxBufferedFrames = Math.Max(120, maxBufferedFrames); + } + + public void Record(double durationMs) + { + if (durationMs <= 0d || double.IsNaN(durationMs) || double.IsInfinity(durationMs)) + return; + + lock (_gate) + { + _durationsMs.Enqueue(durationMs); + if (_durationsMs.Count > _maxBufferedFrames) + _durationsMs.Dequeue(); + } + } + + public bool TryCreateSnapshot(string source, out NativeFrameStatsSnapshot snapshot) + { + List data; + lock (_gate) + { + if (_durationsMs.Count == 0) + { + snapshot = new NativeFrameStatsSnapshot(); + return false; + } + + data = _durationsMs.ToList(); + _durationsMs.Clear(); + } + + data.Sort(); + var avg = data.Average(); + var p50 = Percentile(data, 0.50); + var p95 = Percentile(data, 0.95); + var worst = data[^1]; + + snapshot = new NativeFrameStatsSnapshot + { + TsUtc = DateTime.UtcNow, + Source = source, + Fps = avg > 0d ? 1000d / avg : null, + FrameTimeMsP50 = p50, + FrameTimeMsP95 = p95, + WorstFrameTimeMs = worst, + JankFrameCount = data.Count(frame => frame >= _jankThresholdMs), + UiThreadStallCount = data.Count(frame => frame >= _stallThresholdMs) + }; + return true; + } + + private static double Percentile(IReadOnlyList sorted, double percentile) + { + if (sorted.Count == 0) + return 0d; + + var clamped = Math.Clamp(percentile, 0d, 1d); + var index = (int)Math.Ceiling(sorted.Count * clamped) - 1; + index = Math.Clamp(index, 0, sorted.Count - 1); + return sorted[index]; + } +} + +#if ANDROID +internal sealed class AndroidFrameMetricsStatsProvider : Java.Lang.Object, INativeFrameStatsProvider, Android.Views.Window.IOnFrameMetricsAvailableListener +{ + private readonly FrameStatsAccumulator _accumulator; + private WeakReference? _windowRef; + private Handler? _frameMetricsHandler; + private bool _running; + + public AndroidFrameMetricsStatsProvider() + { + var frameBudgetMs = ResolveFrameBudgetMs(); + _accumulator = new FrameStatsAccumulator(frameBudgetMs); + } + + public static bool IsApiSupported => Build.VERSION.SdkInt >= BuildVersionCodes.N; + public bool IsSupported => IsApiSupported; + public bool ProvidesExactFrameTimings => true; + public string Source => "native.android.framemetrics"; + + public void Start() + { + if (_running) + return; + if (!IsSupported) + throw new PlatformNotSupportedException("FrameMetrics requires Android API 24+."); + + MainThread.BeginInvokeOnMainThread(() => + { + try + { + var activity = Platform.CurrentActivity; + var window = activity?.Window; + if (window == null) + return; + + var looper = Looper.MyLooper() ?? Looper.MainLooper; + if (looper == null) + return; + + _frameMetricsHandler ??= new Handler(looper); + _windowRef = new WeakReference(window); + window.AddOnFrameMetricsAvailableListener(this, _frameMetricsHandler); + _running = true; + } + catch + { + } + }); + } + + public void Stop() + { + _running = false; + MainThread.BeginInvokeOnMainThread(() => + { + if (_windowRef?.TryGetTarget(out var window) == true) + window.RemoveOnFrameMetricsAvailableListener(this); + _windowRef = null; + _frameMetricsHandler = null; + }); + } + + public bool TryCollect(out NativeFrameStatsSnapshot snapshot) + { + if (!_accumulator.TryCreateSnapshot(Source, out snapshot)) + return false; + + snapshot.NativeMemoryBytes = TryReadAndroidNativeMemoryBytes(); + return true; + } + + public void OnFrameMetricsAvailable(Android.Views.Window? window, FrameMetrics? frameMetrics, int dropCountSinceLastInvocation) + { + if (!_running || frameMetrics == null) + return; + + var durationNs = frameMetrics.GetMetric((int)FrameMetricsId.TotalDuration); + if (durationNs <= 0) + return; + + _accumulator.Record(durationNs / 1_000_000d); + } + + public new void Dispose() + { + Stop(); + base.Dispose(); + } + + private static long? TryReadAndroidNativeMemoryBytes() + { + try + { + return Android.OS.Debug.NativeHeapAllocatedSize; + } + catch + { + return null; + } + } + + private static double ResolveFrameBudgetMs() + { + try + { + var refreshRate = DeviceDisplay.Current.MainDisplayInfo.RefreshRate; + if (refreshRate > 1d && !double.IsInfinity(refreshRate) && !double.IsNaN(refreshRate)) + return 1000d / refreshRate; + } + catch + { + } + + return 1000d / 60d; + } +} + +internal sealed class AndroidChoreographerFrameStatsProvider : Java.Lang.Object, INativeFrameStatsProvider, Choreographer.IFrameCallback +{ + private readonly FrameStatsAccumulator _accumulator; + private bool _running; + private long _lastFrameTimeNanos; + + public AndroidChoreographerFrameStatsProvider() + { + var frameBudgetMs = ResolveFrameBudgetMs(); + _accumulator = new FrameStatsAccumulator(frameBudgetMs); + } + + public bool IsSupported => true; + public bool ProvidesExactFrameTimings => false; + public string Source => "native.android.choreographer"; + + public void Start() + { + if (_running) + return; + + _lastFrameTimeNanos = 0; + MainThread.BeginInvokeOnMainThread(() => + { + try + { + Choreographer.Instance.PostFrameCallback(this); + _running = true; + } + catch + { + } + }); + } + + public void Stop() + { + _running = false; + MainThread.BeginInvokeOnMainThread(() => Choreographer.Instance.RemoveFrameCallback(this)); + } + + public bool TryCollect(out NativeFrameStatsSnapshot snapshot) + { + if (!_accumulator.TryCreateSnapshot(Source, out snapshot)) + return false; + + snapshot.NativeMemoryBytes = TryReadAndroidNativeMemoryBytes(); + return true; + } + + public void DoFrame(long frameTimeNanos) + { + if (!_running) + return; + + if (_lastFrameTimeNanos > 0) + { + var durationMs = (frameTimeNanos - _lastFrameTimeNanos) / 1_000_000d; + _accumulator.Record(durationMs); + } + + _lastFrameTimeNanos = frameTimeNanos; + Choreographer.Instance.PostFrameCallback(this); + } + + public new void Dispose() + { + Stop(); + base.Dispose(); + } + + private static double ResolveFrameBudgetMs() + { + try + { + var refreshRate = DeviceDisplay.Current.MainDisplayInfo.RefreshRate; + if (refreshRate > 1d && !double.IsInfinity(refreshRate) && !double.IsNaN(refreshRate)) + return 1000d / refreshRate; + } + catch + { + } + + return 1000d / 60d; + } + + private static long? TryReadAndroidNativeMemoryBytes() + { + try + { + return Android.OS.Debug.NativeHeapAllocatedSize; + } + catch + { + return null; + } + } +} +#endif + +#if IOS || MACCATALYST +internal sealed class AppleDisplayLinkFrameStatsProvider : INativeFrameStatsProvider +{ + private readonly FrameStatsAccumulator _accumulator; + private CADisplayLink? _displayLink; + private bool _running; + private double _lastTimestampSeconds; + + public AppleDisplayLinkFrameStatsProvider() + { + var frameBudgetMs = ResolveFrameBudgetMs(); + _accumulator = new FrameStatsAccumulator(frameBudgetMs); + } + + public bool IsSupported => true; + public bool ProvidesExactFrameTimings => false; + public string Source => "native.apple.cadisplaylink"; + + public void Start() + { + if (_running) + return; + + _lastTimestampSeconds = 0d; + MainThread.BeginInvokeOnMainThread(() => + { + try + { + _displayLink = CADisplayLink.Create(OnTick); + _displayLink.AddToRunLoop(NSRunLoop.Main, NSRunLoopMode.Common); + _running = true; + } + catch + { + } + }); + } + + public void Stop() + { + _running = false; + MainThread.BeginInvokeOnMainThread(() => + { + _displayLink?.Invalidate(); + _displayLink?.Dispose(); + _displayLink = null; + }); + } + + public bool TryCollect(out NativeFrameStatsSnapshot snapshot) + { + if (!_accumulator.TryCreateSnapshot(Source, out snapshot)) + return false; + + snapshot.NativeMemoryBytes = TryReadPhysFootprint(); + return true; + } + + public void Dispose() + { + Stop(); + } + + private void OnTick() + { + if (!_running || _displayLink == null) + return; + + var ts = _displayLink.Timestamp; + if (_lastTimestampSeconds > 0d) + { + var durationMs = (ts - _lastTimestampSeconds) * 1000d; + _accumulator.Record(durationMs); + } + + _lastTimestampSeconds = ts; + } + + private static double ResolveFrameBudgetMs() + { + try + { + var refreshRate = DeviceDisplay.Current.MainDisplayInfo.RefreshRate; + if (refreshRate > 1d && !double.IsInfinity(refreshRate) && !double.IsNaN(refreshRate)) + return 1000d / refreshRate; + } + catch + { + } + + return 1000d / 60d; + } + + private static long? TryReadPhysFootprint() + { + try + { + var info = new MachTaskVmInfoRev1(); + int count = Marshal.SizeOf() / sizeof(int); + int result = mach_task_info(mach_task_self(), TASK_VM_INFO, ref info, ref count); + if (result != 0 || info.PhysFootprint <= 0) + return null; + + return (long)info.PhysFootprint; + } + catch + { + return null; + } + } + + const uint TASK_VM_INFO = 22; + + [DllImport("/usr/lib/libSystem.dylib", EntryPoint = "mach_task_self")] + static extern IntPtr mach_task_self(); + + [DllImport("/usr/lib/libSystem.dylib", EntryPoint = "task_info")] + static extern int mach_task_info(IntPtr targetTask, uint flavor, ref MachTaskVmInfoRev1 info, ref int count); + + // Only fields up through phys_footprint (rev1). The kernel fills only what fits based on count, + // so we don't need the full rev7 struct. This layout has been stable since iOS 7 / OS X 10.9. + [StructLayout(LayoutKind.Sequential)] + struct MachTaskVmInfoRev1 + { + public ulong VirtualSize; + public int RegionCount; + public int PageSize; + public ulong ResidentSize; + public ulong ResidentSizePeak; + public ulong Device; + public ulong DevicePeak; + public ulong Internal; + public ulong InternalPeak; + public ulong External; + public ulong ExternalPeak; + public ulong Reusable; + public ulong ReusablePeak; + public ulong PurgeableVolatilePmap; + public ulong PurgeableVolatileResident; + public ulong PurgeableVolatileVirtual; + public ulong Compressed; + public ulong CompressedPeak; + public ulong CompressedLifetime; + public ulong PhysFootprint; + } +} +#endif + +#if WINDOWS +internal sealed class WindowsCompositionFrameStatsProvider : INativeFrameStatsProvider +{ + private readonly FrameStatsAccumulator _accumulator; + private bool _running; + private TimeSpan? _lastRenderingTime; + + public WindowsCompositionFrameStatsProvider() + { + var frameBudgetMs = ResolveFrameBudgetMs(); + _accumulator = new FrameStatsAccumulator(frameBudgetMs); + } + + public bool IsSupported => true; + public bool ProvidesExactFrameTimings => false; + public string Source => "native.windows.compositiontarget"; + + public void Start() + { + if (_running) + return; + + _lastRenderingTime = null; + MainThread.BeginInvokeOnMainThread(() => + { + try + { + CompositionTarget.Rendering += OnRendering; + _running = true; + } + catch + { + } + }); + } + + public void Stop() + { + _running = false; + MainThread.BeginInvokeOnMainThread(() => CompositionTarget.Rendering -= OnRendering); + } + + public bool TryCollect(out NativeFrameStatsSnapshot snapshot) + { + if (!_accumulator.TryCreateSnapshot(Source, out snapshot)) + return false; + + snapshot.NativeMemoryBytes = TryReadResidentMemoryBytes(); + return true; + } + + public void Dispose() => Stop(); + + private void OnRendering(object? sender, object args) + { + if (!_running || args is not RenderingEventArgs renderingArgs) + return; + + if (_lastRenderingTime.HasValue) + { + var durationMs = (renderingArgs.RenderingTime - _lastRenderingTime.Value).TotalMilliseconds; + _accumulator.Record(durationMs); + } + + _lastRenderingTime = renderingArgs.RenderingTime; + } + + private static long? TryReadResidentMemoryBytes() + { + try + { + return Process.GetCurrentProcess().WorkingSet64; + } + catch + { + return null; + } + } + + private static double ResolveFrameBudgetMs() + { + try + { + var refreshRate = DeviceDisplay.Current.MainDisplayInfo.RefreshRate; + if (refreshRate > 1d && !double.IsInfinity(refreshRate) && !double.IsNaN(refreshRate)) + return 1000d / refreshRate; + } + catch (Exception ex) when ( + ex is InvalidOperationException + || ex is NotSupportedException + || ex is PlatformNotSupportedException) + { + } + + return 1000d / 60d; + } +} +#endif diff --git a/src/MauiDevFlow.Driver/AgentClient.cs b/src/MauiDevFlow.Driver/AgentClient.cs index 6f34918..b22ff1a 100644 --- a/src/MauiDevFlow.Driver/AgentClient.cs +++ b/src/MauiDevFlow.Driver/AgentClient.cs @@ -203,6 +203,58 @@ public async Task HitTestAsync(double x, double y, int? window = null) return await _http.GetStringAsync($"{_baseUrl}{path}"); } + public async Task GetProfilerCapabilitiesAsync() + { + return await GetAsync("/api/profiler/capabilities"); + } + + public async Task StartProfilerAsync(int? sampleIntervalMs = null) + { + object payload = sampleIntervalMs.HasValue + ? new { sampleIntervalMs = sampleIntervalMs.Value } + : new { }; + var response = await PostJsonAsync("/api/profiler/start", payload); + return response?.Session; + } + + public async Task StopProfilerAsync() + { + var response = await PostJsonAsync("/api/profiler/stop", new { }); + return response?.Session; + } + + public async Task GetProfilerSamplesAsync( + long sampleCursor = 0, + long markerCursor = 0, + long spanCursor = 0, + int limit = 500) + { + var url = $"/api/profiler/samples?sampleCursor={sampleCursor}&markerCursor={markerCursor}&spanCursor={spanCursor}&limit={limit}"; + return await GetAsync(url); + } + + public async Task PublishProfilerMarkerAsync( + string name, + string type = "user.action", + string? payloadJson = null) + { + return await PostActionAsync("/api/profiler/marker", new { name, type, payloadJson }); + } + + public async Task> GetProfilerHotspotsAsync( + int limit = 20, + int minDurationMs = 16, + string? kind = null) + { + limit = Math.Clamp(limit, 1, 200); + minDurationMs = Math.Clamp(minDurationMs, 0, 60_000); + + var path = $"/api/profiler/hotspots?limit={limit}&minDurationMs={minDurationMs}"; + if (!string.IsNullOrWhiteSpace(kind)) + path += $"&kind={Uri.EscapeDataString(kind)}"; + return await GetAsync>(path) ?? new(); + } + private async Task GetAsync(string path) where T : class { try @@ -239,6 +291,24 @@ private async Task PostActionAsync(string path, object body) catch { return false; } } + private async Task PostJsonAsync(string path, object body) where T : class + { + try + { + var json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync($"{_baseUrl}{path}", content); + if (!response.IsSuccessStatusCode) + return null; + var responseBody = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(responseBody); + } + catch + { + return null; + } + } + public void Dispose() { if (_disposed) return; @@ -286,6 +356,12 @@ public string GetNetworkWebSocketUrl() var wsBase = _baseUrl.Replace("http://", "ws://").Replace("https://", "wss://"); return $"{wsBase}/ws/network"; } + + private sealed class ProfilerSessionEnvelope + { + [System.Text.Json.Serialization.JsonPropertyName("session")] + public ProfilerSessionInfo? Session { get; set; } + } } public class AgentStatus @@ -353,3 +429,167 @@ public class NetworkRequest [System.Text.Json.Serialization.JsonPropertyName("responseBodyTruncated")] public bool ResponseBodyTruncated { get; set; } } + +public class ProfilerSessionInfo +{ + [System.Text.Json.Serialization.JsonPropertyName("sessionId")] + public string SessionId { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("startedAtUtc")] + public DateTime StartedAtUtc { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("sampleIntervalMs")] + public int SampleIntervalMs { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } +} + +public class ProfilerSample +{ + [System.Text.Json.Serialization.JsonPropertyName("tsUtc")] + public DateTime TsUtc { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("fps")] + public double? Fps { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("frameTimeMsP50")] + public double? FrameTimeMsP50 { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("frameTimeMsP95")] + public double? FrameTimeMsP95 { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("worstFrameTimeMs")] + public double? WorstFrameTimeMs { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("managedBytes")] + public long ManagedBytes { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("gc0")] + public int Gc0 { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("gc1")] + public int Gc1 { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("gc2")] + public int Gc2 { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("nativeMemoryBytes")] + public long? NativeMemoryBytes { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("cpuPercent")] + public double? CpuPercent { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("threadCount")] + public int? ThreadCount { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("jankFrameCount")] + public int JankFrameCount { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("uiThreadStallCount")] + public int UiThreadStallCount { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("frameSource")] + public string FrameSource { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("frameQuality")] + public string FrameQuality { get; set; } = ""; +} + +public class ProfilerMarker +{ + [System.Text.Json.Serialization.JsonPropertyName("tsUtc")] + public DateTime TsUtc { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string Type { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("payloadJson")] + public string? PayloadJson { get; set; } +} + +public class ProfilerBatch +{ + [System.Text.Json.Serialization.JsonPropertyName("sessionId")] + public string SessionId { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("samples")] + public List Samples { get; set; } = new(); + [System.Text.Json.Serialization.JsonPropertyName("markers")] + public List Markers { get; set; } = new(); + [System.Text.Json.Serialization.JsonPropertyName("spans")] + public List Spans { get; set; } = new(); + [System.Text.Json.Serialization.JsonPropertyName("sampleCursor")] + public long SampleCursor { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("markerCursor")] + public long MarkerCursor { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("spanCursor")] + public long SpanCursor { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("isActive")] + public bool IsActive { get; set; } +} + +public class ProfilerSpan +{ + [System.Text.Json.Serialization.JsonPropertyName("spanId")] + public string SpanId { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("parentSpanId")] + public string? ParentSpanId { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("traceId")] + public string? TraceId { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("startTsUtc")] + public DateTime StartTsUtc { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("endTsUtc")] + public DateTime EndTsUtc { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("durationMs")] + public double DurationMs { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("status")] + public string Status { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("threadId")] + public int? ThreadId { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("screen")] + public string? Screen { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("elementPath")] + public string? ElementPath { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("tagsJson")] + public string? TagsJson { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("error")] + public string? Error { get; set; } +} + +public class ProfilerHotspot +{ + [System.Text.Json.Serialization.JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("screen")] + public string? Screen { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("count")] + public int Count { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("errorCount")] + public int ErrorCount { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("avgDurationMs")] + public double AvgDurationMs { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("p95DurationMs")] + public double P95DurationMs { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("maxDurationMs")] + public double MaxDurationMs { get; set; } +} + +public class ProfilerCapabilities +{ + [System.Text.Json.Serialization.JsonPropertyName("available")] + public bool Available { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("supportedInBuild")] + public bool SupportedInBuild { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("featureEnabled")] + public bool FeatureEnabled { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("platform")] + public string Platform { get; set; } = ""; + [System.Text.Json.Serialization.JsonPropertyName("managedMemorySupported")] + public bool ManagedMemorySupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("nativeMemorySupported")] + public bool NativeMemorySupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("gcSupported")] + public bool GcSupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("cpuPercentSupported")] + public bool CpuPercentSupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("fpsSupported")] + public bool FpsSupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("frameTimingsEstimated")] + public bool FrameTimingsEstimated { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("nativeFrameTimingsSupported")] + public bool NativeFrameTimingsSupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("jankEventsSupported")] + public bool JankEventsSupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("uiThreadStallSupported")] + public bool UiThreadStallSupported { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("threadCountSupported")] + public bool ThreadCountSupported { get; set; } +} diff --git a/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs b/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs new file mode 100644 index 0000000..1f4a6f3 --- /dev/null +++ b/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace MauiDevFlow.Tests; + +public class ProfilerAgentClientTests +{ + [Fact] + public async Task Profiler_StartStopAndPollFlow_WorksThroughAgentClient() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var serverTask = Task.Run(async () => + { + for (var i = 0; i < 3; i++) + { + using var client = await listener.AcceptTcpClientAsync(); + using var stream = client.GetStream(); + var request = await ReadRequestAsync(stream); + + if (request.Contains("POST /api/profiler/start", StringComparison.Ordinal)) + { + var body = """ + { + "session": { + "sessionId": "s-1", + "startedAtUtc": "2026-01-01T00:00:00Z", + "sampleIntervalMs": 500, + "isActive": true + }, + "capabilities": { + "available": true + } + } + """; + await WriteJsonResponseAsync(stream, body); + continue; + } + + if (request.Contains("GET /api/profiler/samples", StringComparison.Ordinal)) + { + var body = """ + { + "sessionId": "s-1", + "samples": [ + { + "tsUtc": "2026-01-01T00:00:00.500Z", + "fps": 60.0, + "frameTimeMsP50": 16.6, + "frameTimeMsP95": 20.1, + "worstFrameTimeMs": 48.2, + "managedBytes": 2048, + "nativeMemoryBytes": 8192, + "gc0": 1, + "gc1": 0, + "gc2": 0, + "cpuPercent": 12.5, + "threadCount": 8, + "jankFrameCount": 3, + "uiThreadStallCount": 1, + "frameSource": "native.android.choreographer", + "frameQuality": "estimated" + } + ], + "markers": [ + { + "tsUtc": "2026-01-01T00:00:00.300Z", + "type": "navigation.start", + "name": "//native" + } + ], + "spans": [ + { + "spanId": "sp-1", + "startTsUtc": "2026-01-01T00:00:00.300Z", + "endTsUtc": "2026-01-01T00:00:00.340Z", + "durationMs": 40.0, + "kind": "ui.operation", + "name": "action.scroll", + "status": "ok", + "threadId": 12 + } + ], + "sampleCursor": 1, + "markerCursor": 1, + "spanCursor": 1, + "isActive": true + } + """; + await WriteJsonResponseAsync(stream, body); + continue; + } + + if (request.Contains("POST /api/profiler/stop", StringComparison.Ordinal)) + { + var body = """ + { + "session": { + "sessionId": "s-1", + "startedAtUtc": "2026-01-01T00:00:00Z", + "sampleIntervalMs": 500, + "isActive": false + } + } + """; + await WriteJsonResponseAsync(stream, body); + continue; + } + + throw new InvalidOperationException($"Unexpected request: {request}"); + } + }); + + using var client = new MauiDevFlow.Driver.AgentClient("localhost", port); + + var started = await client.StartProfilerAsync(500); + Assert.NotNull(started); + Assert.Equal("s-1", started.SessionId); + Assert.True(started.IsActive); + + var batch = await client.GetProfilerSamplesAsync(); + Assert.NotNull(batch); + Assert.Equal("s-1", batch.SessionId); + Assert.Single(batch.Samples); + Assert.Single(batch.Markers); + Assert.Single(batch.Spans); + Assert.Equal("native.android.choreographer", batch.Samples[0].FrameSource); + Assert.Equal(3, batch.Samples[0].JankFrameCount); + Assert.Equal(8192, batch.Samples[0].NativeMemoryBytes); + Assert.Equal(1, batch.SampleCursor); + Assert.Equal(1, batch.MarkerCursor); + Assert.Equal(1, batch.SpanCursor); + + var stopped = await client.StopProfilerAsync(); + Assert.NotNull(stopped); + Assert.False(stopped.IsActive); + + await serverTask; + } + + private static async Task ReadRequestAsync(NetworkStream stream) + { + var buffer = new byte[8192]; + var read = await stream.ReadAsync(buffer); + return Encoding.UTF8.GetString(buffer, 0, read); + } + + private static async Task WriteJsonResponseAsync(NetworkStream stream, string body) + { + var bodyBytes = Encoding.UTF8.GetBytes(body); + var headers = $"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {bodyBytes.Length}\r\nConnection: close\r\n\r\n"; + var headerBytes = Encoding.UTF8.GetBytes(headers); + await stream.WriteAsync(headerBytes); + await stream.WriteAsync(bodyBytes); + } +} diff --git a/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs b/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs new file mode 100644 index 0000000..5f13364 --- /dev/null +++ b/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs @@ -0,0 +1,354 @@ +using System.Text.Json; +using System.Runtime.InteropServices; +using MauiDevFlow.Agent.Core.Profiling; +using System.Reflection; +using System.Linq; + +namespace MauiDevFlow.Tests; + +public class ProfilerCoreTests +{ + [Fact] + public void ProfilerBatch_SerializesAndDeserializes() + { + var now = DateTime.UtcNow; + var batch = new ProfilerBatch + { + SessionId = "session-1", + IsActive = true, + SampleCursor = 2, + MarkerCursor = 3, + SpanCursor = 4, + Samples = new() + { + new ProfilerSample + { + TsUtc = now, + Fps = 59.9, + FrameTimeMsP50 = 16.67, + FrameTimeMsP95 = 22.4, + WorstFrameTimeMs = 31.2, + ManagedBytes = 123_456, + NativeMemoryBytes = 654_321, + Gc0 = 10, + Gc1 = 4, + Gc2 = 1, + CpuPercent = 33.2, + ThreadCount = 14, + JankFrameCount = 2, + UiThreadStallCount = 1, + FrameSource = "native.android.choreographer", + FrameQuality = "estimated" + } + }, + Markers = new() + { + new ProfilerMarker + { + TsUtc = now, + Type = "navigation.start", + Name = "//native", + PayloadJson = """{"route":"//native"}""" + } + }, + Spans = new() + { + new ProfilerSpan + { + SpanId = "span-1", + StartTsUtc = now, + EndTsUtc = now.AddMilliseconds(18), + DurationMs = 18, + Kind = "ui.operation", + Name = "action.tap", + Status = "ok" + } + } + }; + + var json = JsonSerializer.Serialize(batch); + var parsed = JsonSerializer.Deserialize(json); + + Assert.NotNull(parsed); + Assert.Equal("session-1", parsed.SessionId); + Assert.True(parsed.IsActive); + Assert.Single(parsed.Samples); + Assert.Single(parsed.Markers); + Assert.Single(parsed.Spans); + Assert.Equal("navigation.start", parsed.Markers[0].Type); + Assert.Equal(4, parsed.SpanCursor); + Assert.Equal(123_456, parsed.Samples[0].ManagedBytes); + Assert.Equal(654_321, parsed.Samples[0].NativeMemoryBytes); + Assert.Equal("native.android.choreographer", parsed.Samples[0].FrameSource); + Assert.Equal(2, parsed.Samples[0].JankFrameCount); + } + + [Fact] + public void ProfilerRingBuffer_OverwritesOldestWhenCapacityReached() + { + var ring = new ProfilerRingBuffer(3); + ring.Add(new ProfilerMarker { Name = "m1", Type = "t", TsUtc = DateTime.UtcNow }); + ring.Add(new ProfilerMarker { Name = "m2", Type = "t", TsUtc = DateTime.UtcNow.AddMilliseconds(1) }); + ring.Add(new ProfilerMarker { Name = "m3", Type = "t", TsUtc = DateTime.UtcNow.AddMilliseconds(2) }); + ring.Add(new ProfilerMarker { Name = "m4", Type = "t", TsUtc = DateTime.UtcNow.AddMilliseconds(3) }); + + var items = ring.ReadAfter(0, 10, out var latestCursor); + + Assert.Equal(4, latestCursor); + Assert.Equal(3, items.Count); + Assert.Equal("m2", items[0].Name); + Assert.Equal("m3", items[1].Name); + Assert.Equal("m4", items[2].Name); + } + + [Fact] + public void ProfilerSessionStore_EnforcesMonotonicMarkerTimestamps() + { + var store = new ProfilerSessionStore(100, 100, 100); + store.Start(500); + + var now = DateTime.UtcNow; + store.AddMarker(new ProfilerMarker { TsUtc = now, Type = "user.action", Name = "first" }); + store.AddMarker(new ProfilerMarker { TsUtc = now.AddMilliseconds(-100), Type = "user.action", Name = "second" }); + + var batch = store.GetBatch(sampleCursor: 0, markerCursor: 0, limit: 100); + + Assert.Equal(2, batch.Markers.Count); + Assert.True(batch.Markers[1].TsUtc > batch.Markers[0].TsUtc); + Assert.Equal("second", batch.Markers[1].Name); + } + + [Fact] + public void ProfilerSessionStore_HotspotsAggregateSpanDurations() + { + var store = new ProfilerSessionStore(100, 100, 100); + store.Start(500); + var now = DateTime.UtcNow; + + store.AddSpan(new ProfilerSpan + { + SpanId = "s1", + StartTsUtc = now, + EndTsUtc = now.AddMilliseconds(40), + Kind = "ui.operation", + Name = "action.scroll", + Status = "ok", + Screen = "//feed" + }); + store.AddSpan(new ProfilerSpan + { + SpanId = "s2", + StartTsUtc = now.AddMilliseconds(50), + EndTsUtc = now.AddMilliseconds(120), + Kind = "ui.operation", + Name = "action.scroll", + Status = "error", + Screen = "//feed" + }); + + var hotspots = store.GetHotspots(limit: 5, minDurationMs: 16, kind: "ui.operation"); + + Assert.Single(hotspots); + Assert.Equal("action.scroll", hotspots[0].Name); + Assert.Equal(2, hotspots[0].Count); + Assert.Equal(1, hotspots[0].ErrorCount); + Assert.True(hotspots[0].P95DurationMs >= 40); + } + + [Fact] + public void RuntimeProfilerCollector_CollectsRuntimeMetrics() + { + var collector = new RuntimeProfilerCollector(); + collector.Start(250); + Thread.Sleep(150); + + var first = collector.TryCollect(out var sample1); + Thread.Sleep(150); + var second = collector.TryCollect(out var sample2); + collector.Stop(); + + Assert.True(first); + Assert.True(second); + Assert.True(sample1.ManagedBytes >= 0); + Assert.True(sample1.Gc0 >= 0); + Assert.StartsWith("managed.", sample1.FrameSource); + Assert.StartsWith("estimated", sample1.FrameQuality); + Assert.True(sample1.Fps > 0); + Assert.True(sample1.FrameTimeMsP95 > 0); + Assert.True(sample2.TsUtc > sample1.TsUtc); + } + + [Fact] + public void ProfilerSessionStore_IsActiveReflectsLifecycle() + { + var store = new ProfilerSessionStore(10, 10, 10); + Assert.False(store.IsActive); + + store.Start(250); + Assert.True(store.IsActive); + + store.Stop(); + Assert.False(store.IsActive); + } + + [Fact] + public void RuntimeProfilerCollector_WhenNativeProviderStartFails_CleansUpAndFallsBackToEstimated() + { + var provider = new ThrowingNativeProvider(); + var collector = new RuntimeProfilerCollector(provider); + + collector.Start(100); + Thread.Sleep(120); + var collected = collector.TryCollect(out var sample); + var capabilities = collector.GetCapabilities(); + collector.Stop(); + + Assert.Equal(1, provider.StartCalls); + Assert.True(provider.StopCalls >= 1); + Assert.True(collected); + Assert.StartsWith("managed.", sample.FrameSource); + Assert.True(capabilities.FrameTimingsEstimated); + Assert.False(capabilities.NativeFrameTimingsSupported); + Assert.False(capabilities.JankEventsSupported); + Assert.False(capabilities.UiThreadStallSupported); + } + + [Fact] + public void ProfilerContractModels_StayAlignedWithDriverModels() + { + AssertCorePropertiesExistInDriver(); + AssertCorePropertiesExistInDriver(); + AssertCorePropertiesExistInDriver(); + AssertCorePropertiesExistInDriver(); + AssertCorePropertiesExistInDriver(); + AssertCorePropertiesExistInDriver(); + AssertCorePropertiesExistInDriver("Available"); + } + + [Fact] + public void AppleTaskInfo_PhysFootprint_StructLayoutIsCorrect() + { + // This test validates that the P/Invoke struct layout for mach task_info + // is correct on the current platform. It runs on macOS (same Mach kernel as iOS). + if (!OperatingSystem.IsMacOS() && !OperatingSystem.IsMacCatalyst() && !OperatingSystem.IsIOS()) + { + // Skip on non-Apple platforms — the P/Invoke is Apple-only. + return; + } + + var info = new MachTaskVmInfoRev1(); + int count = Marshal.SizeOf() / sizeof(int); + int result = mach_task_info(mach_task_self(), 22, ref info, ref count); + + // Verify the syscall succeeded (KERN_SUCCESS = 0) + Assert.Equal(0, result); + + // PhysFootprint must be > 0 for any running process + Assert.True(info.PhysFootprint > 0, $"PhysFootprint was {info.PhysFootprint}, expected > 0"); + + // Allocate 20MB of native memory and touch it to ensure it's paged in + var allocSize = 20 * 1024 * 1024; + IntPtr nativeAlloc = Marshal.AllocHGlobal(allocSize); + try + { + for (int i = 0; i < allocSize; i += 4096) + Marshal.WriteByte(nativeAlloc + i, 1); + + var info2 = new MachTaskVmInfoRev1(); + int count2 = Marshal.SizeOf() / sizeof(int); + int result2 = mach_task_info(mach_task_self(), 22, ref info2, ref count2); + + Assert.Equal(0, result2); + + // PhysFootprint should have grown by at least ~15MB (some overhead variance) + var deltaBytes = (long)info2.PhysFootprint - (long)info.PhysFootprint; + Assert.True(deltaBytes >= 15 * 1024 * 1024, + $"PhysFootprint delta was {deltaBytes / 1024.0 / 1024.0:F1} MB after 20MB allocation, expected >= 15MB"); + } + finally + { + Marshal.FreeHGlobal(nativeAlloc); + } + } + + [DllImport("/usr/lib/libSystem.dylib", EntryPoint = "mach_task_self")] + static extern IntPtr mach_task_self(); + + [DllImport("/usr/lib/libSystem.dylib", EntryPoint = "task_info")] + static extern int mach_task_info(IntPtr targetTask, uint flavor, ref MachTaskVmInfoRev1 info, ref int count); + + [StructLayout(LayoutKind.Sequential)] + struct MachTaskVmInfoRev1 + { + public ulong VirtualSize; + public int RegionCount; + public int PageSize; + public ulong ResidentSize; + public ulong ResidentSizePeak; + public ulong Device; + public ulong DevicePeak; + public ulong Internal; + public ulong InternalPeak; + public ulong External; + public ulong ExternalPeak; + public ulong Reusable; + public ulong ReusablePeak; + public ulong PurgeableVolatilePmap; + public ulong PurgeableVolatileResident; + public ulong PurgeableVolatileVirtual; + public ulong Compressed; + public ulong CompressedPeak; + public ulong CompressedLifetime; + public ulong PhysFootprint; + } + + private static void AssertCorePropertiesExistInDriver(params string[] extraDriverProperties) + { + var coreProperties = typeof(TCore) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToHashSet(StringComparer.Ordinal); + + var driverProperties = typeof(TDriver) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .Concat(extraDriverProperties) + .ToHashSet(StringComparer.Ordinal); + + var missingInDriver = coreProperties + .Where(coreProperty => !driverProperties.Contains(coreProperty)) + .OrderBy(name => name) + .ToArray(); + + Assert.True( + missingInDriver.Length == 0, + $"Driver contract {typeof(TDriver).Name} is missing properties: {string.Join(", ", missingInDriver)}"); + } + + private sealed class ThrowingNativeProvider : INativeFrameStatsProvider + { + public bool IsSupported => true; + public bool ProvidesExactFrameTimings => true; + public string Source => "native.test"; + public int StartCalls { get; private set; } + public int StopCalls { get; private set; } + + public void Start() + { + StartCalls++; + throw new InvalidOperationException("start failed"); + } + + public void Stop() => StopCalls++; + + public bool TryCollect(out NativeFrameStatsSnapshot snapshot) + { + snapshot = new NativeFrameStatsSnapshot(); + return false; + } + + public void Dispose() + { + } + } +}