From f81681c7dbb7ac4d123f277acee88124809b6bef Mon Sep 17 00:00:00 2001 From: oxo <31625368+okisooo@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:44:48 +0900 Subject: [PATCH 1/2] fix: soften popup motion and clarify usage surfaces --- native/FleetPanel/PanelView.cs | 58 ++++++++++++++++++++------------ native/FleetPanel/PanelWindow.cs | 11 ++++-- native/FleetPanel/Theme.cs | 8 ++--- scripts/check-dashboard.py | 21 +++++++++++- 4 files changed, 69 insertions(+), 29 deletions(-) diff --git a/native/FleetPanel/PanelView.cs b/native/FleetPanel/PanelView.cs index 697be45..9ebf6f7 100644 --- a/native/FleetPanel/PanelView.cs +++ b/native/FleetPanel/PanelView.cs @@ -39,8 +39,8 @@ internal sealed partial class PanelView // there, and shows as a hard square around the flyout. public const double Margin = 28; public const double Width = CardWidth + 2 * Margin; - public const double Radius = 8; - private const double Pad = 16; + public const double Radius = 16; + private const double Pad = 18; // Meters line up under the provider's name, past its icon. private const double Indent = 26; private const double LabelColumn = 64; @@ -118,7 +118,7 @@ public PanelView(Theme theme) Background = new SolidColorBrush(theme.Fallback), Effect = new DropShadowEffect { - BlurRadius = 18, ShadowDepth = 7, Direction = 270, Opacity = theme.ShadowOpacity, Color = theme.Shadow, + BlurRadius = 24, ShadowDepth = 5, Direction = 270, Opacity = theme.ShadowOpacity, Color = theme.Shadow, RenderingBias = RenderingBias.Quality, }, IsHitTestVisible = false, @@ -324,7 +324,6 @@ private void Rebuild(bool keepScroll = false) } foreach (var provider in data.Providers) { - content.Children.Add(Divider()); content.Children.Add(Section(provider)); } foreach (var item in data.Inactive) @@ -343,13 +342,15 @@ private void Rebuild(bool keepScroll = false) /// Windows' "Animation effects" switch: off means no motion at all. private static bool Motion => SystemParameters.ClientAreaAnimation; - /// Far enough for the whole card to sit below the taskbar's edge. - private double Drop => Math.Max(64, card.ActualHeight + 16); + /// A short travel keeps large popups from sweeping across the screen. + private double Drop => 12; /// Before showing: the card waits below the taskbar's edge. public void PrepareIntro() { lift.BeginAnimation(TranslateTransform.YProperty, null); + card.BeginAnimation(UIElement.OpacityProperty, null); + card.Opacity = Motion ? 0 : 1; lift.Y = Motion ? Drop : 0; } @@ -360,11 +361,19 @@ public void PlayIntro() { lift.BeginAnimation(TranslateTransform.YProperty, null); lift.Y = 0; + card.Opacity = 1; + card.CacheMode = null; IntroMeasured?.Invoke("frames=0 motion=off"); return; } - busyUntil = DateTime.UtcNow.AddMilliseconds(360); - lift.BeginAnimation(TranslateTransform.YProperty, Anim(Drop, 0, 330, 0, Decelerate)); + busyUntil = DateTime.UtcNow.AddMilliseconds(220); + // Cache the whole surface only during travel: nested blur/shadow layers + // should not be recomposed at every animation frame. Text is live at rest. + card.CacheMode = new BitmapCache { SnapsToDevicePixels = true }; + var entrance = Anim(lift.Y, 0, 200, 0, Decelerate); + entrance.Completed += (s, e) => card.CacheMode = null; + lift.BeginAnimation(TranslateTransform.YProperty, entrance); + card.BeginAnimation(UIElement.OpacityProperty, Anim(card.Opacity, 1, 150, 0, Decelerate)); var started = DateTime.UtcNow; var frames = new List(); if (counting != null) @@ -411,6 +420,7 @@ private static string Pacing(List frames) /// Stop everything that runs per frame: nothing renders while closed. public void Rest() { + card.CacheMode = null; spin.BeginAnimation(RotateTransform.AngleProperty, null); spin.Angle = 0; if (counting != null) @@ -423,8 +433,10 @@ public void Rest() /// The close: the card drops back into the taskbar, speeding up as it goes. public void PlayOutro(Action finished) { - busyUntil = DateTime.UtcNow.AddMilliseconds(220); - var drop = Anim(lift.Y, Drop, 190, 0, Accelerate); + busyUntil = DateTime.UtcNow.AddMilliseconds(140); + card.CacheMode = new BitmapCache { SnapsToDevicePixels = true }; + card.BeginAnimation(UIElement.OpacityProperty, Anim(card.Opacity, 0, 110, 0, Accelerate)); + var drop = Anim(lift.Y, Drop, 120, 0, Accelerate); drop.Completed += (s, e) => finished(); lift.BeginAnimation(TranslateTransform.YProperty, drop); } @@ -447,7 +459,7 @@ private static DoubleAnimation Anim(double from, double to, double ms, double de private UIElement Header(string title, bool back) { - var grid = new Grid { Height = 40 }; + var grid = new Grid { Height = 46 }; grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); var left = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; @@ -457,7 +469,9 @@ private UIElement Header(string title, bool back) button.Margin = new Thickness(-8, 0, 4, 0); left.Children.Add(button); } - left.Children.Add(Text(title, 14, theme.Text, FontWeights.SemiBold)); + if (!back) + left.Children.Add(new Border { Width = 6, Height = 18, CornerRadius = new CornerRadius(3), Background = Brush(theme.Accent), Margin = new Thickness(0, 0, 8, 0) }); + left.Children.Add(Text(title, 16, theme.Text, FontWeights.SemiBold)); grid.Children.Add(left); if (!back || page == "dashboard") { @@ -498,7 +512,7 @@ private UIElement Band() note.Margin = new Thickness(0, 0, 0, 10); return note; } - var grid = new Grid { Margin = new Thickness(0, 2, 0, 12), ToolTip = CostNote, Background = Brushes.Transparent }; + var grid = new Grid { Margin = new Thickness(12, 12, 12, 12), ToolTip = CostNote, Background = Brushes.Transparent }; ToolTipService.SetInitialShowDelay(grid, 400); var cells = new[] { @@ -510,14 +524,14 @@ private UIElement Band() { grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(i == 0 ? 1.25 : 1, GridUnitType.Star) }); var cell = new StackPanel(); - cell.Children.Add(Text(cells[i].Item1, 12, theme.TextSecondary)); - var value = Text(cells[i].Item2 == null ? "—" : Format.Money(cells[i].Item2.Value), 20, theme.Text, FontWeights.SemiBold, tabular: true); + cell.Children.Add(Text(cells[i].Item1, 11, theme.TextSecondary)); + var value = Text(cells[i].Item2 == null ? "—" : Format.Money(cells[i].Item2.Value), 20, i == 0 ? theme.Accent : theme.Text, FontWeights.SemiBold, tabular: true); value.Margin = new Thickness(0, 1, 0, 0); cell.Children.Add(value); Grid.SetColumn(cell, i); grid.Children.Add(cell); } - return grid; + return new Border { Child = grid, CornerRadius = new CornerRadius(12), Background = Brush(Theme.WithAlpha(theme.Accent, theme.Light ? 0.07 : 0.10)), Margin = new Thickness(0, 2, 0, 12) }; } private Border Divider() => new Border @@ -581,17 +595,17 @@ private FrameworkElement Section(Provider p) var hit = new Border { Child = summary, - Padding = new Thickness(8, 7, 8, 7), - Margin = new Thickness(-8, 0, -8, 0), - CornerRadius = new CornerRadius(6), - Background = Brushes.Transparent, + Padding = new Thickness(10, 10, 10, 10), + Margin = new Thickness(0, 0, 0, 7), + CornerRadius = new CornerRadius(11), + Background = Brush(theme.ControlFill), }; if (expandable) { hit.Cursor = Cursors.Hand; AutomationName(hit, p.Name + (open ? ", details shown" : ", show details")); - hit.MouseEnter += (s, e) => hit.Background = Brush(theme.Hover); - hit.MouseLeave += (s, e) => hit.Background = Brushes.Transparent; + hit.MouseEnter += (s, e) => hit.Background = Brush(theme.ControlHover); + hit.MouseLeave += (s, e) => hit.Background = Brush(theme.ControlFill); OnClick(hit, () => Toggle(p.Id)); } diff --git a/native/FleetPanel/PanelWindow.cs b/native/FleetPanel/PanelWindow.cs index ee2c9e0..e44861b 100644 --- a/native/FleetPanel/PanelWindow.cs +++ b/native/FleetPanel/PanelWindow.cs @@ -709,10 +709,17 @@ private void StartLiveGlass() { return; } - live.Start(placedLeft, placedTop, placedWidth, placedHeight); + var started = false; liveFrame = (s, e) => { - if (!open || closing || view.Animating || !live.TryTake(ref liveBytes, out var w, out var h, out var lum)) + if (!open || closing || view.Animating) return; + // Screen capture competes with composition; begin it after the entrance. + if (!started) + { + live.Start(placedLeft, placedTop, placedWidth, placedHeight); + started = true; + } + if (!live.TryTake(ref liveBytes, out var w, out var h, out var lum)) { return; } diff --git a/native/FleetPanel/Theme.cs b/native/FleetPanel/Theme.cs index e4d5a91..2a18a7d 100644 --- a/native/FleetPanel/Theme.cs +++ b/native/FleetPanel/Theme.cs @@ -73,9 +73,9 @@ public static Theme Create(bool light) StrongStroke = C(0x72000000), StrongFill = C(0x72000000), OnAccent = C(0xFFFFFFFF), Track = C(0x14000000), Accent = accent, Caution = C(0xFFE08A00), Critical = C(0xFFC42B1C), CautionText = C(0xFF9D5D00), CriticalText = C(0xFFC42B1C), - Shadow = C(0xFF000000), ShadowOpacity = 0.20, + Shadow = C(0xFF172238), ShadowOpacity = 0.15, Fallback = C(0xFFF3F3F3), Luminosity = C(0xFFF9F9F9), Tint = C(0xFFFCFCFC), - LuminosityOpacity = 0.86, TintOpacity = 0.0, Grain = 0.022, + LuminosityOpacity = 0.90, TintOpacity = 0.0, Grain = 0.010, }; } return new Theme @@ -88,9 +88,9 @@ public static Theme Create(bool light) StrongStroke = C(0x8BFFFFFF), StrongFill = C(0x8BFFFFFF), OnAccent = C(0xFF000000), Track = C(0x1FFFFFFF), Accent = accent, Caution = C(0xFFF7C948), Critical = C(0xFFFF6B78), CautionText = C(0xFFFCE100), CriticalText = C(0xFFFF99A4), - Shadow = C(0xFF000000), ShadowOpacity = 0.42, + Shadow = C(0xFF000000), ShadowOpacity = 0.32, Fallback = C(0xFF202020), Luminosity = C(0xFF2C2C2C), Tint = C(0xFF2C2C2C), - LuminosityOpacity = 0.92, TintOpacity = 0.32, Grain = 0.022, + LuminosityOpacity = 0.92, TintOpacity = 0.32, Grain = 0.010, }; } diff --git a/scripts/check-dashboard.py b/scripts/check-dashboard.py index c7d1741..4eac11a 100644 --- a/scripts/check-dashboard.py +++ b/scripts/check-dashboard.py @@ -31,7 +31,9 @@ flags=BindingFlags.Public|BindingFlags.NonPublic|BindingFlags.Instance|BindingFlags.Static checks=[] def call(obj,name,*args): - return obj.GetType().GetMethod(name,flags).Invoke(obj,Array[Object](args)) + method=next(m for m in obj.GetType().GetMethods(flags) + if m.Name==name and len(m.GetParameters())==len(args)) + return method.Invoke(obj,Array[Object](args)) def field(obj,name): return obj.GetType().GetField(name,flags).GetValue(obj) def walk(root): @@ -116,6 +118,23 @@ def verify(): assert app.Windows.Count==1 assert not (scratch/'panel-error.log').exists() checks.append('closing resets to usage; no extra window or native error') + card=field(view,'card');lift=field(view,'lift') + for i in range(4): + call(parent,'Open');pump(450) + assert abs(lift.Y)<0.01 and abs(card.Opacity-1)<0.01 + assert card.CacheMode is None + call(parent,'Close',Boolean(True));pump(200) + assert not field(parent,'open') and parent.Left < -10000 + checks.append('four complete open/close cycles settle opaque, uncached and offscreen') + call(parent,'Open');pump(40) + call(parent,'Close',Boolean(True));pump(30) + call(parent,'Open');pump(450) + assert field(parent,'open') and not field(parent,'closing') and abs(card.Opacity-1)<0.01 + checks.append('rapid close/reopen stays open and restores opacity') + call(parent,'Close',Boolean(True));pump(200) + assert lift.Y<=12.01 and card.CacheMode is None + checks.append('travel is bounded to 12 DIP and no animation cache remains while closed') + assert not (scratch/'panel-error.log').exists() except Exception as error: checks.append('FAILED: '+str(error)) finally: From 4fd00ac6cbd96061b9b49092f2ce376492fe0132 Mon Sep 17 00:00:00 2001 From: oxo <31625368+okisooo@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:00:26 +0900 Subject: [PATCH 2/2] perf: reduce panel refresh and idle rendering work --- CHANGELOG.md | 12 ++ docs/performance.md | 66 +++++++++++ native/FleetPanel/LiveBackdrop.cs | 30 +++-- native/FleetPanel/Model.cs | 8 +- native/FleetPanel/PanelView.cs | 24 +++- native/FleetPanel/PanelWindow.cs | 71 ++++++++++-- native/FleetPanel/Program.cs | 28 +++-- native/FleetPanel/ScreenCapture.cs | 2 +- scripts/check-panel-performance.py | 170 +++++++++++++++++++++++++++++ scripts/measure-panel.py | 59 ++++++++++ 10 files changed, 436 insertions(+), 34 deletions(-) create mode 100644 docs/performance.md create mode 100644 scripts/check-panel-performance.py create mode 100644 scripts/measure-panel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 48da758..0efe5c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # changelog +## unreleased - panel efficiency + +- preserved the installed popup's layout, fonts, themes, controls and navigation. +- kept controls intact on timestamp-only refreshes and reused frozen brushes. +- replaced frequent file checks with debounced notifications and a recovery timer. +- paced live-glass handoff to its 60 Hz capture rate while retaining display-rate + entrance and exit motion; stopped capture workers before reopening. +- bounded panel error logs and closed window-owned timers and capture resources. +- added reproducible, isolated memory, refresh and navigation checks; see + [performance checks](docs/performance.md). collectors and account behavior are + unchanged. + ## 0.4.1 - 2026-10-26 - changed **Dashboard** to switch pages inside the existing tray popup. removed diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..3eda0fa --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,66 @@ +# panel performance checks + +Fleet keeps its existing native WPF panel, controls, fonts, themes and collectors. +The panel reuses frozen brushes, keeps its controls on timestamp-only model +updates, and watches cache-file notifications with a one-second recovery timer. +The normal tray-window message path is unchanged. File notifications are debounced +so creating and writing a request does not toggle the popup twice. + +Live glass still captures up to 60 frames per second. Its UI handoff now runs only +at that rate, instead of subscribing continuously to display-rate rendering. +Entrance and exit animations retain their display-rate animation. Closing stops +and joins the capture worker; closing the window disposes its watcher, timers and +capture resources. Error logging keeps two bounded files and omits exception +messages that could contain input. + +## reproduce + +Use a synthetic `panel.json` generated with `panel_model.build`, including +`summary`, `coverage`, `api_accounts` and dashboard history. Do not copy credentials +or a user's raw logs into a fixture. Install `pythonnet` and `psutil` in a test +environment. Both checks require Windows; neither collects provider data. + +```powershell +python scripts/measure-panel.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR 3 +python scripts/check-panel-performance.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR +python scripts/check-dashboard.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR +``` + +Use different output directories for each binary and run. Keep all generated +artifacts, virtual environments, package caches, TEMP and TMP on the requested +scratch/output drive. Do not run performance checks concurrently. + +`measure-panel.py` starts real standalone panel processes with isolated caches, +samples fresh-hidden, open and closed-after-open states, then requests graceful +exit. Its windows stay offscreen and use the real open/close and capture paths. +It reports private resident memory (USS), total working set, private commit, +process CPU time, handles and threads separately. CPU time is per process, not a +percentage of every logical processor. + +`check-panel-performance.py` hosts the same assembly in Python/CLR to exercise 100 +timestamp-only refreshes and 100 open/close/navigation cycles. It checks that +changed content still replaces controls, and that coverage, settings, summary and +account changes are not lost. Its memory samples include Python/CLR test overhead +and must not be presented as the standalone app's footprint. Managed allocation +totals include the host's other managed work. No check forces garbage collection +or trims the working set. + +## acceptance and limits + +Compare identical fixtures, display settings, timing and process age. Separate +startup memory from the memory after interaction. Check late-cycle ranges for +private resident memory, working set, private commit, kernel handles, GDI objects +and threads. A short plateau is evidence for that workload, not a guarantee about +multi-day behavior. + +For appearance, render each binary with `--render` against the same synthetic +model and background. Compare usage, dashboard, settings and expanded-provider +pages in both themes. Static pixel parity does not by itself prove animation, +multi-monitor, or live-background parity; use the dashboard interaction check and +a real desktop smoke check as well. + +Unit tests should not depend on a developer's real Credential Manager entries. +An unisolated provider-enablement test can discover an already-connected admin +account. For reproducible local tests, mock `api_credentials.read` to return +`None` around test discovery/execution; credential-specific tests use their own +mocks. Never clear real credentials to make a test pass. diff --git a/native/FleetPanel/LiveBackdrop.cs b/native/FleetPanel/LiveBackdrop.cs index 5f9509c..24aebc5 100644 --- a/native/FleetPanel/LiveBackdrop.cs +++ b/native/FleetPanel/LiveBackdrop.cs @@ -16,6 +16,7 @@ internal sealed class LiveBackdrop private readonly object gate = new object(); private Thread thread; private volatile bool running; + private readonly ManualResetEventSlim stopping = new ManualResetEventSlim(false); private int left, top, width, height; private byte[] frame; private int frameWidth, frameHeight; @@ -41,6 +42,7 @@ public void Start(int l, int t, int w, int h) totalMs = 0; } running = true; + stopping.Reset(); thread = new Thread(Run) { IsBackground = true, Name = "fleet-glass" }; thread.Start(); } @@ -56,7 +58,15 @@ public void Move(int l, int t, int w, int h) } } - public void Stop() => running = false; + public void Stop() + { + running = false; + stopping.Set(); + // A new open must not revive the previous worker before it exits. + // Wake its paced wait immediately; only an in-flight copy can remain. + thread?.Join(); + thread = null; + } /// The newest frame, if one arrived since the last call. public bool TryTake(ref byte[] target, out int w, out int h, out double lum) @@ -100,7 +110,7 @@ private void Run() } if (w <= 0 || h <= 0) { - Thread.Sleep(16); + stopping.Wait(16); continue; } var cw = Math.Max(1, w / ScreenCapture.Downscale); @@ -128,7 +138,7 @@ private void Run() clock.Stop(); if (!ok) { - Thread.Sleep(16); + stopping.Wait(16); continue; } // Sixty copies a second while something behind moves: under a blur that @@ -144,7 +154,7 @@ private void Run() } if (rest >= 1) { - Thread.Sleep((int)Math.Ceiling(rest)); + stopping.Wait((int)Math.Ceiling(rest)); } lock (gate) { @@ -174,16 +184,18 @@ private void Run() private static void Free(ref IntPtr dc, ref IntPtr dib) { - if (dib != IntPtr.Zero) - { - Native.DeleteObject(dib); - dib = IntPtr.Zero; - } + // Release the DC's selection before deleting its bitmap. GDI refuses + // DeleteObject while the bitmap is still selected into a live DC. if (dc != IntPtr.Zero) { Native.DeleteDC(dc); dc = IntPtr.Zero; } + if (dib != IntPtr.Zero) + { + Native.DeleteObject(dib); + dib = IntPtr.Zero; + } } } } diff --git a/native/FleetPanel/Model.cs b/native/FleetPanel/Model.cs index 570bbbd..838f137 100644 --- a/native/FleetPanel/Model.cs +++ b/native/FleetPanel/Model.cs @@ -11,6 +11,9 @@ namespace FleetPanel internal sealed class PanelData { public double Ts; + // Compare every field except the collection timestamp. Freshness, account + // status and coverage timestamps remain part of the displayed content. + public string ContentKey; public int Live; public int Cached; public List Providers = new List(); @@ -27,7 +30,8 @@ internal sealed class PanelData public static PanelData Load(string path) { var text = File.ReadAllText(path); - var root = new JavaScriptSerializer { MaxJsonLength = 8 * 1024 * 1024 }.DeserializeObject(text) + var serializer = new JavaScriptSerializer { MaxJsonLength = 8 * 1024 * 1024 }; + var root = serializer.DeserializeObject(text) as Dictionary; var data = new PanelData(); if (root == null) @@ -150,6 +154,8 @@ public static PanelData Load(string path) data.SkippedArchives = Num(coverage, "skipped_zst"); data.LogUpdated = Num(coverage, "updated"); data.LogSince = Num(coverage, "since"); } + root.Remove("ts"); + data.ContentKey = serializer.Serialize(root); return data; } diff --git a/native/FleetPanel/PanelView.cs b/native/FleetPanel/PanelView.cs index 9ebf6f7..f996aaa 100644 --- a/native/FleetPanel/PanelView.cs +++ b/native/FleetPanel/PanelView.cs @@ -69,6 +69,10 @@ internal sealed partial class PanelView private readonly List tickers = new List(); private readonly Dictionary sections = new Dictionary(); private readonly Dictionary masks = new Dictionary(); + // Frozen brushes are safe to share, including the small custom drawings. + // Bound this cache even if a future theme supplies arbitrary colours. + private static readonly Dictionary brushes = new Dictionary(); + private string renderedKey; private readonly RotateTransform spin = new RotateTransform(); private PanelData data = new PanelData(); private string page = "usage"; @@ -237,7 +241,13 @@ public void Show(PanelData next, bool animate = false) } // A scheduled data push must not erase a key while the user types. if (page == "settings" && apiEditing != "") return; - if (apiFeedback == "Key saved. Checking connection…") apiFeedback = ""; + var feedbackChanged = apiFeedback == "Key saved. Checking connection…"; + if (feedbackChanged) apiFeedback = ""; + if (!feedbackChanged && data.ContentKey != null && renderedKey == data.ContentKey) + { + Tick(); + return; + } Rebuild(keepScroll: true); } @@ -283,6 +293,7 @@ public void Select(string id) private void Rebuild(bool keepScroll = false) { + renderedKey = data.ContentKey; var offset = keepScroll ? scroller.VerticalOffset : 0; apiInput?.Clear(); apiInput = null; @@ -1806,9 +1817,14 @@ private static void AutomationName(DependencyObject element, string name) => private static SolidColorBrush Brush(Color color) { - var brush = new SolidColorBrush(color); - brush.Freeze(); - return brush; + lock (brushes) + { + if (brushes.TryGetValue(color, out var existing)) return existing; + var brush = new SolidColorBrush(color); + brush.Freeze(); + if (brushes.Count < 128) brushes[color] = brush; + return brush; + } } } } diff --git a/native/FleetPanel/PanelWindow.cs b/native/FleetPanel/PanelWindow.cs index e44861b..762b077 100644 --- a/native/FleetPanel/PanelWindow.cs +++ b/native/FleetPanel/PanelWindow.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Web.Script.Serialization; using System.Windows; using System.Windows.Input; @@ -40,7 +41,11 @@ internal sealed class PanelWindow : Window private readonly ScreenCapture capture = new ScreenCapture(); // Input priority sits above rendering: a busy high-refresh render loop must // never starve the timer that hears the tray and writes the heartbeat. - private readonly DispatcherTimer signalTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(100) }; + private readonly DispatcherTimer signalTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromSeconds(1) }; + private FileSystemWatcher signals; + private int signalQueued; + private volatile bool closed; + private readonly DispatcherTimer signalDebounce = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(50) }; private readonly DispatcherTimer clockTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromSeconds(1) }; private readonly DispatcherTimer dismissTimer = new DispatcherTimer(DispatcherPriority.Input) { Interval = TimeSpan.FromMilliseconds(90) }; // Click-away watching only runs while the flyout is open. @@ -99,6 +104,7 @@ public PanelWindow(string cache) dismissTimer.Tick += (s, e) => { dismissTimer.Stop(); if (!IsActive && open) Close(animate: true); }; KeyDown += (s, e) => { if (e.Key == Key.Escape) Close(animate: true); }; signalTimer.Tick += (s, e) => OnSignalTick(); + signalDebounce.Tick += (s, e) => { signalDebounce.Stop(); pendingModel = true; OnSignalTick(); }; clockTimer.Tick += (s, e) => view.Tick(); clickTimer.Tick += (s, e) => { if (open && !closing) WatchOutsideClicks(); }; } @@ -128,10 +134,52 @@ public void Start() }; warm.Start(); signalSeen = Mtime("panel.show"); + // Native messages remain the normal instant path. File notifications + // keep the fallback responsive without checking the disk ten times/s. + try + { + signals = new FileSystemWatcher(cache) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite }; + signals.Changed += SignalChanged; + signals.Created += SignalChanged; + signals.Renamed += SignalChanged; + signals.EnableRaisingEvents = true; + } + catch (IOException) { signals?.Dispose(); signals = null; } signalTimer.Start(); WriteHeartbeat(); } + private void SignalChanged(object sender, FileSystemEventArgs e) + { + if (e.Name != "panel.show" && e.Name != "dashboard.show" && e.Name != "panel.quit" && e.Name != "panel.json") return; + if (closed || Dispatcher.HasShutdownStarted || Interlocked.Exchange(ref signalQueued, 1) != 0) return; + try { Dispatcher.BeginInvoke(new Action(() => + { + Interlocked.Exchange(ref signalQueued, 0); + if (closed) return; + // Create/write/rename can all describe the same request. Let the + // writer finish before reading its timestamp and toggling once. + signalDebounce.Stop(); + signalDebounce.Start(); + }), DispatcherPriority.Input); } + catch (InvalidOperationException) { Interlocked.Exchange(ref signalQueued, 0); } + } + + protected override void OnClosed(EventArgs e) + { + closed = true; + signals?.Dispose(); + signalTimer.Stop(); + signalDebounce.Stop(); + clockTimer.Stop(); + dismissTimer.Stop(); + clickTimer.Stop(); + StopLiveGlass(); + view.Rest(); + capture.Dispose(); + base.OnClosed(e); + } + private void BuildView() { view = new PanelView(Theme.Create(light)); @@ -369,7 +417,7 @@ private void LoadModel() catch (Exception exception) { modelSeen = stamp; - Note("panel.json unreadable: " + exception.Message); + Note("panel.json unreadable: " + exception.GetType().Name); } } @@ -412,13 +460,7 @@ private bool WriteDirect(string name, string text) private void Note(string message) { - try - { - File.AppendAllText(PathOf("panel-error.log"), DateTime.Now.ToString("s") + " " + message + Environment.NewLine); - } - catch (Exception) - { - } + Program.Note(cache, message); } private void Touch(string name) @@ -699,6 +741,11 @@ private bool Cloaked() => // UI swaps in the newest copy when nothing is moving, about 0.1 ms. Capture // count and cost are logged on close. private readonly LiveBackdrop live = new LiveBackdrop(); + // Capture produces at most 60 frames/s. A permanent Rendering handler + // otherwise keeps WPF requesting frames at the monitor's full refresh + // rate even when no new pixels arrived. Entrance/exit motion retains + // its independent display-rate animation and frame measurement. + private readonly DispatcherTimer liveTimer = new DispatcherTimer(DispatcherPriority.Render) { Interval = TimeSpan.FromSeconds(1.0 / 60) }; private EventHandler liveFrame; private WriteableBitmap liveBitmap; private byte[] liveBytes; @@ -730,7 +777,8 @@ private void StartLiveGlass() liveBitmap.WritePixels(new Int32Rect(0, 0, w, h), liveBytes, w * 4, 0); view.SetBackdrop(liveBitmap, lum); }; - CompositionTarget.Rendering += liveFrame; + liveTimer.Tick += liveFrame; + liveTimer.Start(); } private void StopLiveGlass() @@ -740,7 +788,8 @@ private void StopLiveGlass() { return; } - CompositionTarget.Rendering -= liveFrame; + liveTimer.Stop(); + liveTimer.Tick -= liveFrame; liveFrame = null; var captures = live.Captures; if (captures > 0) diff --git a/native/FleetPanel/Program.cs b/native/FleetPanel/Program.cs index a2dd5e0..2b47ac9 100644 --- a/native/FleetPanel/Program.cs +++ b/native/FleetPanel/Program.cs @@ -56,14 +56,9 @@ public static int Main(string[] args) // A fault in the flyout leaves a note for the tray log instead of vanishing. app.DispatcherUnhandledException += (s, e) => { - try - { - File.AppendAllText(Path.Combine(cache, "panel-error.log"), - DateTime.Now.ToString("s") + " " + e.Exception + Environment.NewLine); - } - catch (Exception) - { - } + // Exception messages may include input. Keep type and stack, + // never credential text, and bound both the entry and the file. + Note(cache, e.Exception.GetType().FullName + Environment.NewLine + e.Exception.StackTrace); }; WatchParent(app); var window = new PanelWindow(cache); @@ -79,6 +74,23 @@ private static string Arg(string[] args, string name) return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } + internal static void Note(string cache, string message) + { + try + { + var path = Path.Combine(cache, "panel-error.log"); + if (File.Exists(path) && new FileInfo(path).Length > 64 * 1024) + { + var previous = path + ".previous"; + if (File.Exists(previous)) File.Delete(previous); + File.Move(path, previous); + } + if (message.Length > 4096) message = message.Substring(0, 4096); + File.AppendAllText(path, DateTime.Now.ToString("s") + " " + message + Environment.NewLine); + } + catch (Exception) { } + } + /// No orphan flyout: leave when the tray that started us is gone. private static void WatchParent(Application app) { diff --git a/native/FleetPanel/ScreenCapture.cs b/native/FleetPanel/ScreenCapture.cs index 26e5a20..76a0fa5 100644 --- a/native/FleetPanel/ScreenCapture.cs +++ b/native/FleetPanel/ScreenCapture.cs @@ -132,8 +132,8 @@ private void Allocate(int w, int h) private void Release() { - if (dib != IntPtr.Zero) { Native.DeleteObject(dib); dib = IntPtr.Zero; } if (memoryDc != IntPtr.Zero) { Native.DeleteDC(memoryDc); memoryDc = IntPtr.Zero; } + if (dib != IntPtr.Zero) { Native.DeleteObject(dib); dib = IntPtr.Zero; } } public void Dispose() => Release(); diff --git a/scripts/check-panel-performance.py b/scripts/check-panel-performance.py new file mode 100644 index 0000000..3490e0a --- /dev/null +++ b/scripts/check-panel-performance.py @@ -0,0 +1,170 @@ +"""Exercise the real WPF panel with a synthetic model in an isolated process. + +Usage: python scripts/check-panel-performance.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR +Requires pythonnet and psutil. Reports host-process memory separately from the +standalone benchmark; Python/CLR test overhead is included here. Never forces GC. +""" +import ctypes +import json +import os +from pathlib import Path +import sys +import time + +import psutil + +os.environ['FLEET_PANEL_OFFSCREEN'] = '1' +exe, fixture, out = map(Path, sys.argv[1:4]) +out.mkdir(parents=True, exist_ok=True) +cache = out / 'cache' +cache.mkdir(exist_ok=True) +model = json.loads(fixture.read_text()) +(cache/'panel.json').write_text(json.dumps(model)) +import clr +for name in ('WindowsBase', 'PresentationCore', 'PresentationFramework'): + clr.AddReference(str(Path(r'C:\Windows\Microsoft.NET\Framework64\v4.0.30319\WPF')/(name+'.dll'))) +from System import Activator, AppDomain, Array, Boolean, EventHandler, GC, Object, TimeSpan +from System.Reflection import Assembly, BindingFlags +from System.Threading import ApartmentState, Thread, ThreadStart +from System.Windows import Application, ShutdownMode +from System.Windows.Threading import Dispatcher, DispatcherFrame, DispatcherTimer + +assembly = Assembly.LoadFile(str(exe.resolve())) +flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static +process = psutil.Process() +AppDomain.MonitoringIsEnabled = True +samples, checks = [], [] +ctypes.windll.user32.GetGuiResources.argtypes = [ctypes.c_void_p, ctypes.c_uint] +ctypes.windll.user32.GetGuiResources.restype = ctypes.c_uint +ctypes.windll.kernel32.GetCurrentProcess.restype = ctypes.c_void_p + + +def call(obj, name, *args): + method = next(m for m in obj.GetType().GetMethods(flags) + if m.Name == name and len(m.GetParameters()) == len(args)) + return method.Invoke(obj, Array[Object](args)) + + +def field(obj, name): + return obj.GetType().GetField(name, flags).GetValue(obj) + + +def pump(ms=40): + frame, timer = DispatcherFrame(), DispatcherTimer() + timer.Interval = TimeSpan.FromMilliseconds(ms) + def end(sender, event): + timer.Stop() + frame.Continue = False + handler = EventHandler(end) + timer.Tick += handler + timer.Start() + Dispatcher.PushFrame(frame) + timer.Tick -= handler + + +def sample(cycle): + m = process.memory_full_info() + handle = ctypes.windll.kernel32.GetCurrentProcess() + samples.append(dict(cycle=cycle, private_resident=m.uss, working_set=m.rss, + private_commit=m.private, handles=process.num_handles(), + gdi=ctypes.windll.user32.GetGuiResources(handle, 0), + user=ctypes.windll.user32.GetGuiResources(handle, 1), + threads=process.num_threads(), managed_live=GC.GetTotalMemory(False), + allocated=AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize)) + + +def run(): + app = None + try: + app = Application() + app.ShutdownMode = ShutdownMode.OnExplicitShutdown + parent = Activator.CreateInstance(assembly.GetType('FleetPanel.PanelWindow'), Array[Object]([str(cache.resolve())])) + call(parent, 'Start') + pump(1300) + view = field(parent, 'view') + content = field(view, 'content') + call(view, 'ShowDashboard') + parent.UpdateLayout() + original = content.Children[0] + start = AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize + clock = time.perf_counter() + reused = 0 + for i in range(100): + model['ts'] += 1 + (cache/'panel.json').write_text(json.dumps(model)) + call(parent, 'LoadModel') + pump(5) + reused += int(content.Children[0] == original) + refresh = dict(count=100, reused=reused, elapsed_ms=(time.perf_counter()-clock)*1000, + allocated=AppDomain.CurrentDomain.MonitoringTotalAllocatedMemorySize-start) + # A real value change must still replace the controls. + model['summary']['today'] += 1 + (cache/'panel.json').write_text(json.dumps(model)) + call(parent, 'LoadModel') + pump() + assert content.Children[0] != original + checks.append('changed content rebuilds the dashboard') + if assembly.GetType('FleetPanel.PanelData').GetField('ContentKey') is not None: + assert reused == 100 + checks.append('100 timestamp-only refreshes retain existing controls') + saved = json.loads(json.dumps(model)) + # Every meaningful status remains in the comparison, including the + # timestamps that describe the source's freshness and log coverage. + for section, key, value in [('coverage', 'updated', 42), + ('settings', 'cost', False), + ('summary', 'state', 'indexing')]: + previous = content.Children[0] + model[section][key] = value + (cache/'panel.json').write_text(json.dumps(model)) + call(parent, 'LoadModel') + pump() + assert content.Children[0] != previous + previous = content.Children[0] + model['api_accounts'][0]['source'] = 'test-source' + (cache/'panel.json').write_text(json.dumps(model)) + call(parent, 'LoadModel') + pump() + assert content.Children[0] != previous + checks.append('coverage, settings, summary and account status changes still rebuild') + model.clear() + model.update(saved) + (cache/'panel.json').write_text(json.dumps(model)) + call(parent, 'LoadModel') + pump() + sample(0) + for i in range(1, 101): + call(parent, 'Open') + pump(360) # settle entrance, including starting the real glass worker + call(view, 'ShowDashboard') + parent.UpdateLayout() + call(view, 'ShowSettings') + parent.UpdateLayout() + call(view, 'ShowUsage') + parent.UpdateLayout() + call(parent, 'Close', Boolean(False)) + pump(80) + assert not field(parent, 'open') and not field(parent, 'closing') + if i % 10 == 0: + sample(i) + (out/'progress.json').write_text(json.dumps(samples, indent=2)) + checks.append('100 open/close and usage/dashboard/settings navigation cycles complete') + call(parent, 'Close') # Window.Close, not the private Close(bool) + pump(200) + sample(101) + assert not (cache/'panel-error.log').exists() + result = dict(checks=checks, refresh=refresh, samples=samples) + (out/'result.json').write_text(json.dumps(result, indent=2)) + print(json.dumps(dict(checks=checks, refresh=refresh)), flush=True) + except Exception as error: + (out/'failure.txt').write_text(str(error)) + print('FAILED:', error, flush=True) + finally: + if app is not None: + app.Shutdown() + + +thread = Thread(ThreadStart(run)) +thread.SetApartmentState(ApartmentState.STA) +thread.Start() +thread.Join() +sys.exit(1 if (out/'failure.txt').exists() else 0) diff --git a/scripts/measure-panel.py b/scripts/measure-panel.py new file mode 100644 index 0000000..43a98a1 --- /dev/null +++ b/scripts/measure-panel.py @@ -0,0 +1,59 @@ +"""Bounded, secret-free panel benchmark. Never uses the installed cache. + +Usage: python scripts/measure-panel.py PANEL_EXE SAMPLE_JSON OUTPUT_DIR [RUNS] +Requires psutil. All UI commands use Fleet's existing cache-file protocol. +Private resident (USS), total working set, and private commit are separate. +No heap collection, trimming, or changes to the system or installed Fleet. +""" +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +import psutil + +exe, fixture, out = map(Path, sys.argv[1:4]) +out.mkdir(parents=True, exist_ok=True) +results = [] + + +def sample(process, phase, seconds=8): + cpu = process.cpu_times() + start = time.perf_counter() + points = [] + for _ in range(seconds): + time.sleep(1) + m = process.memory_full_info() + points.append(dict(private_resident=m.uss, working_set=m.rss, + private_commit=m.private, handles=process.num_handles(), + threads=process.num_threads())) + end = process.cpu_times() + return dict(phase=phase, seconds=time.perf_counter()-start, + cpu_ms=1000*((end.user-cpu.user)+(end.system-cpu.system)), points=points) + + +for run in range(int(sys.argv[4]) if len(sys.argv) > 4 else 3): + cache = out / str(run) + cache.mkdir(exist_ok=True) + (cache/'panel.json').write_bytes(fixture.read_bytes()) + env = dict(os.environ, FLEET_PANEL_OFFSCREEN='1') + env.pop('FLEET_PARENT_PID', None) + child = subprocess.Popen([str(exe.resolve()), '--cache', str(cache.resolve())], env=env) + process = psutil.Process(child.pid) + try: + time.sleep(3) + phases = [sample(process, 'fresh-hidden')] + (cache/'panel.show').write_text('{}') + time.sleep(2) + phases.append(sample(process, 'open')) + (cache/'panel.show').write_text('{}') + time.sleep(2) + phases.append(sample(process, 'closed-after-open')) + results.append(dict(run=run, pid=child.pid, phases=phases)) + finally: + (cache/'panel.quit').touch() + child.wait(timeout=10) + (out/'samples.json').write_text(json.dumps(results, indent=2)) + print('completed run', run, flush=True)