From 85808d1d1637d68276cc6914a49ce5339088c7b9 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 20:12:38 +0200 Subject: [PATCH 1/2] Make three guards able to fail where the 0.3.0 gate found they could not The full mutation run before 0.3.0 left three entries alive, and each was a test that no longer reached what its name promised. The plan panel contrast guard now picks a driver beside the two services. Since the warnings got their own template, the plain sentence template draws only the problem list, and a plan over two services has no problems, so its colour could go without the guard noticing. A driver is refused on sight by the core, which puts a sentence into that list, and the test now asserts the sentence is on the panel. The wiring of the search box to the list under it is held by a new window test that types key by key and then moves the caret alone. The old test wrote a row, which since the suggestions rework tells the list itself, so it stayed green with both events unwired. The two typing helpers moved out of KeyboardTests into SearchBoxKeys, because the new test would have pushed that file over the share of its size ceiling. Co-Authored-By: Claude Opus 5.5 --- tests/Bws.Gui.Tests/KeyboardTests.cs | 48 +-------------- tests/Bws.Gui.Tests/PlanViewGuards.cs | 23 +++++++- tests/Bws.Gui.Tests/SearchBoxKeys.cs | 62 ++++++++++++++++++++ tests/Bws.Gui.Tests/SuggestingWindowTests.cs | 40 +++++++++++++ 4 files changed, 123 insertions(+), 50 deletions(-) create mode 100644 tests/Bws.Gui.Tests/SearchBoxKeys.cs create mode 100644 tests/Bws.Gui.Tests/SuggestingWindowTests.cs diff --git a/tests/Bws.Gui.Tests/KeyboardTests.cs b/tests/Bws.Gui.Tests/KeyboardTests.cs index 593731e..ae162ed 100644 --- a/tests/Bws.Gui.Tests/KeyboardTests.cs +++ b/tests/Bws.Gui.Tests/KeyboardTests.cs @@ -1,5 +1,6 @@ using System.Windows.Input; using Bws.Gui.ViewModels; +using static Bws.Gui.Tests.SearchBoxKeys; namespace Bws.Gui.Tests; @@ -511,51 +512,4 @@ public void Control_F_on_the_overview_screen_opens_no_list() private static Shortcut Wanted(MainWindow window, Key key, bool inTheBox, bool inTheGrid) => WpfHost.On(() => window.Wanted(key, ModifierKeys.None, inTheBox, inTheGrid)); - - /// - /// What the box holds after somebody typed it, with the keyboard in the box. - /// - /// SETTLED FIRST, AND THAT LINE COST AN HOUR ON 2026-09-16. The box's Text binding - /// attaches LAZILY - queued to the data-bind engine at DataBind priority when the window is - /// built - and an Invoke from the test thread runs at Send, ahead of that queue. So text set - /// into a freshly built window was overwritten a moment later by the binding attaching and - /// transferring the model's empty query into the box: the list then held the six examples - /// rather than the fields, and only sometimes. Found from a stack trace on TextChanged - /// (BindingExpression.AttachToContext under DataBindEngine.Run), not by reasoning. A shown - /// window has drained that queue long before anybody types - this host never shows one. - /// - /// - /// Characters typed into the box the way a keyboard types them - through the text input event - /// the box's own editor answers - rather than by setting its text. - /// - /// The difference is the order of the box's two events, and since 2026-09-25 the list cares. - /// Setting Text reports the new text with the caret at zero and moves the caret after, which the - /// list reads as typing followed by a caret move - and a caret move closes it. A keystroke - /// inserts at the caret and reports the text with the caret already past what it inserted. - /// - private static void Keyed(MainWindow window, string characters) => - WpfHost.On(() => - { - foreach (var character in characters) - { - var composition = new TextComposition(InputManager.Current, window.Search.Box, character.ToString()); - - window.Search.Box.RaiseEvent(new TextCompositionEventArgs(Keyboard.PrimaryDevice, composition) - { - RoutedEvent = TextCompositionManager.TextInputEvent - }); - } - }); - - private static void Typed(MainWindow window, string text, int caret) - { - WpfHost.Settled(); - - WpfHost.On(() => - { - window.Search.Box.Text = text; - window.Search.Box.CaretIndex = caret; - ((MainViewModel)window.DataContext).Suggesting.Keyboard(present: true); - }); - } } diff --git a/tests/Bws.Gui.Tests/PlanViewGuards.cs b/tests/Bws.Gui.Tests/PlanViewGuards.cs index 3f84d3c..4f51948 100644 --- a/tests/Bws.Gui.Tests/PlanViewGuards.cs +++ b/tests/Bws.Gui.Tests/PlanViewGuards.cs @@ -331,7 +331,11 @@ public void It_takes_no_focus_of_its_own_and_still_takes_clicks() [Fact] public async Task Every_word_on_the_panel_can_be_read_on_the_surface_it_is_drawn_on() { - var window = await Ready(); + // WITH THE DRIVER, OR THE SENTENCE TEMPLATE IS NEVER BUILT. Since the warnings got their own + // template (PR 17) the plain sentence template draws only the problem list, and a plan over + // two services has no problems - so its colour could go and this guard stayed green, which + // the full gate before 0.3.0 caught as a mutation nobody killed (backlog 464). + var window = await Ready(withTheDriver: true); var model = WpfHost.On(() => (MainViewModel)window.DataContext); Assert.True(await WpfHost.On(() => window.Preview(ActionKind.Stop))); @@ -379,6 +383,7 @@ public async Task Every_word_on_the_panel_can_be_read_on_the_surface_it_is_drawn Assert.True(read.Count(line => !string.IsNullOrWhiteSpace(line)) >= 5, "Too few lines were found on the panel, so nothing was measured."); Assert.Contains(WpfHost.On(() => model.Planned.Heading.Text), read); Assert.Contains(WpfHost.On(() => model.Planned.Steps[0].Text), read); + Assert.Contains(WpfHost.On(() => model.Planned.Problems[0]), read); Assert.True( thin.Count == 0, @@ -472,7 +477,7 @@ private static double Channel(byte value) return part <= 0.04045 ? part / 12.92 : Math.Pow((part + 0.055) / 1.055, 2.4); } - private static async Task Ready() + private static async Task Ready(bool withTheDriver = false) { var machine = new LiveMachine( Rows.Entry("Spooler", "Print Spooler"), @@ -480,7 +485,14 @@ private static async Task Ready() Rows.Entry("Dnscache", "DNS Client"), Rows.Driver("amdkmdag")); - var model = new MainViewModel(machine, new SteppedClock()); + // THE DRIVER IS ON THE LIST ONLY WHEN ASKED FOR, because the Services list is the default and + // most of this file is about the panel over services. Over Everything it is picked beside the + // two services, and the core refuses every action on a driver (PlanBuilder.RefusedOnSight) - + // which is the one sure way to put a sentence into the problem section. + var model = new MainViewModel(machine, new SteppedClock()) + { + Scope = withTheDriver ? EntryScope.Everything : EntryScope.Services + }; await model.LoadAsync(); @@ -495,6 +507,11 @@ private static async Task Ready() window.Entries.ItemsSource = model.Rows; window.Entries.SelectedItem = model.Rows.First(row => row.ServiceName == "Spooler"); window.Entries.SelectedItems.Add(model.Rows.First(row => row.ServiceName == "W32Time")); + + if (withTheDriver) + { + window.Entries.SelectedItems.Add(model.Rows.First(row => row.ServiceName == "amdkmdag")); + } }); WpfHost.Settled(); diff --git a/tests/Bws.Gui.Tests/SearchBoxKeys.cs b/tests/Bws.Gui.Tests/SearchBoxKeys.cs new file mode 100644 index 0000000..a495971 --- /dev/null +++ b/tests/Bws.Gui.Tests/SearchBoxKeys.cs @@ -0,0 +1,62 @@ +using System.Windows.Input; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// The two ways a test puts text into the window's search box - set, or typed key by key - shared by +/// every file that asks the window about the list under the box. +/// +/// Out of KeyboardTests on 2026-09-28, when a new test of the box's wiring would have pushed +/// that file past the share of its ceiling the size ratchet allows. One copy of each, because the +/// two long notes below are what the helpers are worth and a second copy would drift from the first. +/// +internal static class SearchBoxKeys +{ + /// + /// Characters typed into the box the way a keyboard types them - through the text input event + /// the box's own editor answers - rather than by setting its text. + /// + /// The difference is the order of the box's two events, and since 2026-09-25 the list cares. + /// Setting Text reports the new text with the caret at zero and moves the caret after, which the + /// list reads as typing followed by a caret move - and a caret move closes it. A keystroke + /// inserts at the caret and reports the text with the caret already past what it inserted. + /// + internal static void Keyed(MainWindow window, string characters) => + WpfHost.On(() => + { + foreach (var character in characters) + { + var composition = new TextComposition(InputManager.Current, window.Search.Box, character.ToString()); + + window.Search.Box.RaiseEvent(new TextCompositionEventArgs(Keyboard.PrimaryDevice, composition) + { + RoutedEvent = TextCompositionManager.TextInputEvent + }); + } + }); + + /// + /// What the box holds after somebody typed it, with the keyboard in the box. + /// + /// SETTLED FIRST, AND THAT LINE COST AN HOUR ON 2026-09-16. The box's Text binding + /// attaches LAZILY - queued to the data-bind engine at DataBind priority when the window is + /// built - and an Invoke from the test thread runs at Send, ahead of that queue. So text set + /// into a freshly built window was overwritten a moment later by the binding attaching and + /// transferring the model's empty query into the box: the list then held the six examples + /// rather than the fields, and only sometimes. Found from a stack trace on TextChanged + /// (BindingExpression.AttachToContext under DataBindEngine.Run), not by reasoning. A shown + /// window has drained that queue long before anybody types - this host never shows one. + /// + internal static void Typed(MainWindow window, string text, int caret) + { + WpfHost.Settled(); + + WpfHost.On(() => + { + window.Search.Box.Text = text; + window.Search.Box.CaretIndex = caret; + ((MainViewModel)window.DataContext).Suggesting.Keyboard(present: true); + }); + } +} diff --git a/tests/Bws.Gui.Tests/SuggestingWindowTests.cs b/tests/Bws.Gui.Tests/SuggestingWindowTests.cs new file mode 100644 index 0000000..c053835 --- /dev/null +++ b/tests/Bws.Gui.Tests/SuggestingWindowTests.cs @@ -0,0 +1,40 @@ +using Bws.Gui.ViewModels; +using static Bws.Gui.Tests.SearchBoxKeys; + +namespace Bws.Gui.Tests; + +/// +/// The list under the search box as the WINDOW wires it - the box's events reaching the model. +/// Everything the list decides is asked of the model without a window (SuggestingTests, +/// SuggestingTypingTests), and the keys that belong to it are in KeyboardTests. +/// +public sealed class SuggestingWindowTests +{ + /// + /// The box's two events reach the list from the window - its text through typing, its caret + /// through a move that changes no text. + /// + /// Not through writing a row, which is where this used to be held: since PR 22 writing a + /// row tells the list itself (Suggesting.Wrote), so a test of it stayed green with both events + /// unwired - the full gate before 0.3.0 found that as two mutations nobody killed, backlog 464. + /// + [Fact] + public void Typing_in_the_box_opens_the_list_and_a_caret_moved_alone_closes_it() + { + var window = WpfHost.Window(); + var model = WpfHost.On(() => (MainViewModel)window.DataContext); + + Typed(window, string.Empty, 0); + Keyed(window, "sta"); + + Assert.True(model.Suggesting.IsOpen); + Assert.Equal(["status", "start"], model.Suggesting.Offered.Select(row => row.Word)); + + WpfHost.On(() => window.Search.Box.CaretIndex = 1); + + Assert.Equal("sta", WpfHost.On(() => window.Search.Box.Text)); + Assert.False(model.Suggesting.IsOpen); + + WpfHost.On(window.Close); + } +} From e46852db794d526f7427d232786baecba76f27f0 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Mon, 28 Sep 2026 23:40:16 +0200 Subject: [PATCH 2/2] Stop polling while minimised and parse the query once per keystroke The first batch of window fixes from the external performance report of 2026-09-28, each backed by a new test and a mutation that proves it can fail. A minimised window no longer reads the service list every second. WPF leaves IsVisible true on a minimised window, so the timer only ever stopped when the window was hidden. The timer now follows the window state as well, and a restore asks at once rather than a second later. A window started minimised reads once for its first look and then waits the same way. One keystroke parsed the same query eighteen times, once to narrow the list, once for the scope sentence and once for each of the sixteen filter chips. The core gains QueryAsTyped, the text together with its parse, and the window keeps one beside the query text and replaces it whenever the text differs. The chips still hold nothing of their own. Smaller changes in the same vein: - the details panel rebuilds only when a cell or the name changes, not for every shape and highlight a row raises - the second pass announces itself by saying the sentence rather than by rereading the whole list - the plan sheet renders its commands once per plan and builds its warnings once per plan and state of the offer - the row highlight fades once per tick instead of twice - a row works out its start type label and account once per full reading Cost figures in comments that were measured before the readings ran in parallel are replaced with dated figures from the measurements of 2026-09-28, and three comments that claimed the version and hash come from the file already opened for the signature now say that each answer opens it. The method count ceiling in ShapeCeilings goes down from 79 to 78, as the ratchet asks when the largest type shrinks. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + src/Bws.Cli/CommandLine.cs | 13 +- src/Bws.Cli/OptionSurface.cs | 6 +- src/Bws.Cli/Program.cs | 18 ++- src/Bws.Cli/Refusals.cs | 6 +- src/Bws.Core/IBinaryInspector.cs | 6 +- src/Bws.Core/MemoryPass.cs | 3 +- src/Bws.Core/Planning/BulkPlanBuilder.cs | 5 +- src/Bws.Core/Planning/PlanRunner.cs | 9 +- src/Bws.Core/Querying/QueryAsTyped.cs | 81 +++++++++++ src/Bws.Core/Querying/QueryField.cs | 8 +- src/Bws.Core/Querying/QueryFields.cs | 4 +- src/Bws.Core/Querying/QueryMembers.cs | 29 +--- src/Bws.Core/RequiredByPass.cs | 9 +- src/Bws.Core/ScmDetailReader.cs | 2 +- src/Bws.Core/ScmEntry.cs | 36 ++--- src/Bws.Core/SecondPass.cs | 6 +- src/Bws.Core/WindowsScmCatalog.cs | 15 +- src/Bws.Gui/MainWindow.Columns.cs | 2 +- src/Bws.Gui/MainWindow.HandOver.cs | 9 +- src/Bws.Gui/MainWindow.xaml.cs | 72 +++++++--- src/Bws.Gui/ViewModels/Catalogue.Specimens.cs | 9 +- src/Bws.Gui/ViewModels/CellOrder.cs | 4 +- src/Bws.Gui/ViewModels/Chosen.cs | 12 ++ src/Bws.Gui/ViewModels/ColumnMenu.cs | 2 +- src/Bws.Gui/ViewModels/Columns.cs | 7 +- src/Bws.Gui/ViewModels/EntryRow.cs | 15 +- src/Bws.Gui/ViewModels/FilterChips.cs | 34 +++-- src/Bws.Gui/ViewModels/MainViewModel.cs | 62 +++++++-- src/Bws.Gui/ViewModels/Narrowing.cs | 5 + src/Bws.Gui/ViewModels/Planned.Running.cs | 2 +- src/Bws.Gui/ViewModels/Planned.cs | 45 +++++- src/Bws.Gui/ViewModels/Readings.OneEntry.cs | 4 +- .../ViewModels/Readings.SecondPhase.cs | 14 +- src/Bws.Gui/ViewModels/Readings.cs | 16 ++- src/Bws.Gui/ViewModels/RowIndex.cs | 8 +- src/Bws.Gui/ViewModels/RowList.cs | 11 ++ src/Bws.Gui/ViewModels/Says.cs | 2 +- src/Bws.Gui/ViewModels/Scopes.cs | 6 +- tests/Bws.Architecture.Tests/ShapeCeilings.cs | 10 +- tests/Bws.Gui.Tests/ChosenGuards.cs | 33 +++++ tests/Bws.Gui.Tests/ColumnMenuGuards.cs | 12 +- tests/Bws.Gui.Tests/MinimisedWindowGuards.cs | 128 ++++++++++++++++++ tests/Bws.Gui.Tests/PlanSheetListsTests.cs | 53 ++++++++ tests/Bws.Gui.Tests/QueryExampleTests.cs | 2 +- tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs | 103 ++++++++++++++ .../SecondPassSettingOutTests.cs | 70 ++++++++++ tests/Bws.Gui.Tests/SuggestingTests.cs | 2 +- .../PerformanceBudgetTests.cs | 9 +- 49 files changed, 848 insertions(+), 177 deletions(-) create mode 100644 src/Bws.Core/Querying/QueryAsTyped.cs create mode 100644 tests/Bws.Gui.Tests/MinimisedWindowGuards.cs create mode 100644 tests/Bws.Gui.Tests/PlanSheetListsTests.cs create mode 100644 tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs create mode 100644 tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f453bbf..6a8b4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ is not part of this repository. ## [Unreleased] +### Changed + +- A minimised window no longer reads the list of services every second. It reads it again the + moment it is restored, so the list is current as soon as it is on screen. A window started + minimised, from a shortcut set to "Run: Minimized", reads it once and then waits the same way. + ## [0.3.0] - 2026-09-25 ### Added diff --git a/src/Bws.Cli/CommandLine.cs b/src/Bws.Cli/CommandLine.cs index 6b5020f..842e9cc 100644 --- a/src/Bws.Cli/CommandLine.cs +++ b/src/Bws.Cli/CommandLine.cs @@ -38,9 +38,11 @@ internal sealed partial record CommandLine /// /// Read who signed each binary, which the listing does not do by default. /// - /// Measured at 4620-7656 ms over 810 entries and 544 distinct files, against 476-551 ms - /// for the rest of a listing, so it is asked for rather than assumed. A query about - /// signatures turns it on by itself - somebody who wrote signed:no has already asked. + /// Measured 2026-09-28 at about 12 s of processor over 797 entries and 531 distinct files - + /// 1.0-1.2 s of clock on sixteen processors, 4.3-5.0 s on two - against 136-144 ms for the + /// cold reading of the manager every command makes. (It was 4620-7656 ms on 2026-08-01, one + /// file at a time.) So it is asked for rather than assumed. A query about signatures turns it + /// on by itself - somebody who wrote signed:no has already asked. /// /// The spread is wider than most whole operations here, which is itself the point: one /// run of this tells nobody anything. @@ -106,8 +108,9 @@ internal sealed partial record CommandLine /// /// Whether the listing should go and read who depends on each entry. /// - /// Asked for rather than always read: a call per entry, measured at 236-259 ms over 313 - /// services on 2026-09-05, against 423-500 ms for the whole listing. A query naming the + /// Asked for rather than always read: a call per entry, measured 2026-09-28 at 148-155 ms over + /// 797 entries, against 107-113 ms for the whole reading on sixteen processors - more than the + /// listing it sits behind. A query naming the /// field turns it on by itself, exactly as one about signatures or memory does. /// internal bool RequiredBy { get; private init; } diff --git a/src/Bws.Cli/OptionSurface.cs b/src/Bws.Cli/OptionSurface.cs index b3faa11..2ae1160 100644 --- a/src/Bws.Cli/OptionSurface.cs +++ b/src/Bws.Cli/OptionSurface.cs @@ -143,8 +143,8 @@ internal static readonly (string Option, CommandKind[] Verbs)[] Surface = // Listing only. A plan never asks who signed anything, so accepting it on a write // verb would be a switch that does nothing - the silence this table was built to - // end. Measured at 4620-7656 ms over 810 entries and 544 files, which is why it is - // asked for rather than assumed. + // end. It costs about 12 s of processor over 797 entries (2026-09-28, 1.0-1.2 s of clock on + // sixteen processors), which is why it is asked for rather than assumed. ("--signatures", [CommandKind.List]), // Listing only, for the same reason: a plan is about what will happen to a service, @@ -153,7 +153,7 @@ internal static readonly (string Option, CommandKind[] Verbs)[] Surface = // Listing only, and NOT the same switch as --dependents further down. That one is an // instruction to a write verb - take the services standing on this one with you. This is - // a reading, and it costs a call per entry: 236-259 ms over 313 services. + // a reading, and it costs a call per entry: 148-155 ms over 797 entries, 2026-09-28. ("--required-by", [CommandKind.List]), // The two verbs that resolve a launch path against the disk. A plan does not - it diff --git a/src/Bws.Cli/Program.cs b/src/Bws.Cli/Program.cs index 30dbbc4..a0f10ae 100644 --- a/src/Bws.Cli/Program.cs +++ b/src/Bws.Cli/Program.cs @@ -77,7 +77,8 @@ // Comparing two files never opens the service control manager, and it matters that it // does not. A pipeline step comparing two snapshots on a build agent has no business // needing rights over that agent's own services, and reading eight hundred entries this - // branch never looks at would spend half a second saying nothing. + // branch never looks at would spend a cold reading of the manager saying nothing - 136-144 ms + // here and 215-234 ms on an eight-processor machine, measured 2026-09-28. var offline = options.Kind == CommandKind.SnapshotDiff && !options.Live; // Skip unless somebody said otherwise, everywhere, including the branches that never see @@ -94,8 +95,9 @@ var read = offline ? 0 : stopwatch.ElapsedMilliseconds; // Its own number, and not folded into the time spent filtering. The second pass is by - // far the most expensive thing this tool does - measured at around five seconds against - // a third of one for the read - and reporting it under the word "filtered" would put a + // far the most expensive thing this tool does - 869-954 ms against 136-144 ms for the read on + // sixteen processors, and 1451-1591 ms against 215-234 ms on eight, both measured 2026-09-28 + // through --timing - and reporting it under the word "filtered" would put a // true number next to a sentence about something else. long inspected = 0; @@ -188,7 +190,8 @@ // nothing to preview that the file itself does not already say. // // Refused before the expensive work rather than after it, and the order is the whole - // courtesy: verifying signatures takes about a second, and spending it to then say + // courtesy: verifying signatures takes about a second here and half as much again on an + // eight-processor machine (2026-09-28), and spending it to then say // "there is already a file there" would be a second nobody got anything for. // // Found 2026-08-02 by reading a security document from another project, and confirmed @@ -386,9 +389,10 @@ else { // The second pass, and the first thing in this tool that is asked for rather than - // simply done. Measured at 4620-7656 ms over 810 entries and 544 distinct files - // against 476-551 ms for everything above, so a listing does not verify signatures - // unless somebody wants them. + // simply done. Measured 2026-09-28 at about 12 s of processor over 797 entries and 531 + // distinct files - 1.0-1.2 s of clock on sixteen processors and 4.3-5.0 s on two - against + // 136-144 ms for the reading above, so a listing does not verify signatures unless + // somebody wants them. // // A query about them counts as wanting them. Answering "signed:no" with an empty // list because nobody had looked would be a correct query returning what reads diff --git a/src/Bws.Cli/Refusals.cs b/src/Bws.Cli/Refusals.cs index e44b887..5b42614 100644 --- a/src/Bws.Cli/Refusals.cs +++ b/src/Bws.Cli/Refusals.cs @@ -19,7 +19,8 @@ namespace Bws.Cli; /// /// Not one of these opens a handle or reads an entry, which is the property that makes the /// whole block worth having in front of the expensive work. Refusing a typo after eight hundred -/// entries have been read is half a second spent to say "you made a typo". +/// entries have been read is a whole cold reading spent to say "you made a typo" - 136-144 ms here +/// and 215-234 ms on an eight-processor machine, measured 2026-09-28. /// internal static class Refusals { @@ -178,7 +179,8 @@ internal static class Refusals // WERE ASKED AFTER THE MACHINE HAD BEEN READ. That is the one property this whole class // exists for: nothing here opens a handle, so a typo is answered before eight hundred // entries are enumerated. Both of these commands read the manager first and then said - // "you left out an argument" - about half a second spent to report a missing word. + // "you left out an argument" - a whole cold reading of the manager spent to report a + // missing word. // // Nothing about the answers changed. Same two sentences, same code, and the order they are // asked in is the order they stood in. diff --git a/src/Bws.Core/IBinaryInspector.cs b/src/Bws.Core/IBinaryInspector.cs index acc1e64..c9f7b6c 100644 --- a/src/Bws.Core/IBinaryInspector.cs +++ b/src/Bws.Core/IBinaryInspector.cs @@ -9,9 +9,9 @@ namespace Bws.Core; /// need nothing the manager can grant or refuse. Something that only lists services should /// not have to be handed an object that can open and hash every binary on the machine. /// -/// It is also the seam ADR-13 needs. The second pass is expensive - measured at 4620-7656 -/// ms over 810 entries and 544 distinct files, against 476-551 ms for everything the -/// listing does today - so it has to be possible to build a listing without one of these +/// It is also the seam ADR-13 needs. The second pass is expensive - about 12 s of processor +/// over 797 entries and 531 distinct files on 2026-09-28, against 107-113 ms of clock for +/// everything else the listing does - so it has to be possible to build a listing without one of these /// at all. /// public interface IBinaryInspector diff --git a/src/Bws.Core/MemoryPass.cs b/src/Bws.Core/MemoryPass.cs index 00af51a..0575525 100644 --- a/src/Bws.Core/MemoryPass.cs +++ b/src/Bws.Core/MemoryPass.cs @@ -5,7 +5,8 @@ namespace Bws.Core; /// /// Its own pass rather than part of the listing, and the reason is not cost - measured on /// 2026-08-01, the whole pass costs 5-8 ms over 810 entries and 110 processes, beside the -/// 476-551 ms the listing already spends. The calls themselves are under a millisecond and +/// 476-551 ms the listing spent then (107-113 ms over 797 entries on 2026-09-28, since it reads +/// several entries at once). The calls themselves are under a millisecond and /// the rest is building 810 new records, which is worth separating: the probe measured only /// the calls and the figure it gave was wrong by a factor of six. /// diff --git a/src/Bws.Core/Planning/BulkPlanBuilder.cs b/src/Bws.Core/Planning/BulkPlanBuilder.cs index 69ef167..cec8f1d 100644 --- a/src/Bws.Core/Planning/BulkPlanBuilder.cs +++ b/src/Bws.Core/Planning/BulkPlanBuilder.cs @@ -90,8 +90,9 @@ public BulkPlan Build(BulkAction action) /// not the order, which was harmless, but the question asked to work it out: /// spends one ReadDependents per name, and each of /// those opens the manager and then the service. Selecting three hundred entries and asking to - /// disable them paid three hundred round trips to the manager, on the thread drawing the - /// window, to sort a list whose order did not matter. + /// disable them paid three hundred round trips to the manager - on the thread drawing the + /// window, as working out a plan then was, until 2026-09-03 - to sort a list whose order did + /// not matter. /// /// A setting that carries a stop goes back to the ordered side (2026-09-24). Then each /// plan does take its entry down, and two selected entries where one depends on the other have diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs index 3cad34f..975bac8 100644 --- a/src/Bws.Core/Planning/PlanRunner.cs +++ b/src/Bws.Core/Planning/PlanRunner.cs @@ -22,7 +22,14 @@ public sealed class PlanRunner(IScmControl control, IClock clock) /// Our choice, not the system's, and it carries no correctness: the deadline comes from /// the entry's own wait hint, this only decides how soon we notice. Short because /// somebody is watching a terminal and most stops are over in well under a second, and - /// asking costs one call to the manager. + /// asking is cheap - five calls to the manager (open it, open the entry, close the manager, + /// query, close the entry - WindowsScmControl.Read), not the one call this said until + /// 2026-09-28. + /// + /// Measured on the throwaway machine on 2026-09-28, this cadence is most of every wait: + /// Spooler and W32Time changed state in 2-50 ms and each step reported 260-289 ms, because the + /// first question goes straight after the request, is almost always too early, and the next + /// one is a whole cadence later. Backlog 466, S-6 of the external performance report. /// private static readonly TimeSpan Cadence = TimeSpan.FromMilliseconds(250); diff --git a/src/Bws.Core/Querying/QueryAsTyped.cs b/src/Bws.Core/Querying/QueryAsTyped.cs new file mode 100644 index 0000000..74311f7 --- /dev/null +++ b/src/Bws.Core/Querying/QueryAsTyped.cs @@ -0,0 +1,81 @@ +namespace Bws.Core.Querying; + +/// +/// A query as it stands in the box, parsed once and then asked as often as anybody likes. +/// +/// Why it exists - G-4 of the external performance report of 2026-09-28. One keystroke in the +/// window's search box used to parse the same line eighteen times: once to narrow the list, once to +/// ask whether it names what the scope leaves out, and once for each of the sixteen filter chips +/// asking whether it is lit. Each parse of a line holding a pattern builds a new regular expression, +/// and a pattern the engine refuses throws and is caught on every one of them (S-10 of the same +/// report). Everything that asks about one text now asks this. +/// +/// It is the TEXT and the answer together, never the answer alone, and that is what keeps +/// it from being the second copy of the query the filter chips are forbidden to hold - the rule is +/// at FilterChip in the window. Whoever keeps one of these keeps it next to the text it was made +/// from and replaces it the moment the text differs, so it cannot disagree with the box. +/// +/// Parsed as being typed (), because the box is the one +/// place a line is read while it is still being written. The command line reads a finished query +/// and has no use for this. +/// +/// Not safe to share between threads - the members of a line that does not parse are worked +/// out on the first question and kept. The window asks from its own thread only. +/// +public sealed class QueryAsTyped +{ + private IReadOnlyList? _members; + + private QueryAsTyped(string text, QueryParseResult parsed) + { + Text = text; + Parsed = parsed; + } + + /// The text, exactly as it was typed - an absent text is an empty one. + public string Text { get; } + + /// The whole line parsed, once. + public QueryParseResult Parsed { get; } + + /// Parses the text once. + public static QueryAsTyped Of(string? text) => + new(text ?? string.Empty, QueryParser.Parse(text, QueryInput.BeingTyped)); + + /// + /// Whether the text already carries this member, on this side. + /// + /// MEMBER BY MEMBER WHEN THE WHOLE LINE DOES NOT PARSE - UX-GUI-002, 2026-09-23. Until + /// then a mistake anywhere in the line meant no member counted, so in + /// status:running pid:abc the chip for Running went dark over its own member, and + /// clicking it wrote a second copy on the end. The whole line is still asked first, because + /// that is the reading that folds repeated fields - and a line that cannot be taken apart at + /// all, an unclosed quote, carries nothing, for the reason + /// gives. The members are parsed once for every question, not once per question. + /// + public bool Carries(string field, string value, bool negated) + { + if (Parsed.IsValid) + { + return Parsed.Query!.Carries(field, value, negated); + } + + return Members().Any(member => member?.Carries(field, value, negated) ?? false); + } + + private IReadOnlyList Members() => _members ??= Scanned(Text); + + /// + /// Each member of the line on its own, parsed rather than compared as text - which is the whole + /// of why quoting cannot fool a chip (the note at the top of ). + /// + private static IReadOnlyList Scanned(string text) + { + if (text.Length == 0 || !QueryScanner.TryScan(text, out _, out var spans, out _)) + { + return []; + } + + return [.. spans.Select(span => QueryParser.Parse(text[span], QueryInput.BeingTyped).Query)]; + } +} diff --git a/src/Bws.Core/Querying/QueryField.cs b/src/Bws.Core/Querying/QueryField.cs index 7fd0eb2..796149a 100644 --- a/src/Bws.Core/Querying/QueryField.cs +++ b/src/Bws.Core/Querying/QueryField.cs @@ -40,18 +40,18 @@ public enum ExtraRead { None = 0, - /// Who signed each binary. Measured at 4620-7656 ms over 810 entries. + /// Who signed each binary. About 12 s of processor over 797 entries, measured 2026-09-28. Signatures = 1, /// What each running process is using. Measured at under a millisecond over 110 processes. Memory = 2, /// - /// Who breaks if each entry stops. Measured at 236-259 ms over 313 services on 2026-09-05. + /// Who breaks if each entry stops. Measured at 148-155 ms over 797 entries on 2026-09-28. /// /// The third family, and the one that shows why this was flags rather than a yes-or-no from - /// the start. It sits between the other two - two hundred and fifty milliseconds against - /// under one and against seven and a half seconds - so a query about dependents must not send + /// the start. It sits between the other two - a hundred and fifty milliseconds against + /// under one and against twelve seconds of processor - so a query about dependents must not send /// the window to open eight hundred binaries, and a question about signatures must not walk the /// manager service by service. Each caller asks for what it needs and gets only that, which /// Readings.Fill honours one flag at a time. diff --git a/src/Bws.Core/Querying/QueryFields.cs b/src/Bws.Core/Querying/QueryFields.cs index aa3dac7..38ed480 100644 --- a/src/Bws.Core/Querying/QueryFields.cs +++ b/src/Bws.Core/Querying/QueryFields.cs @@ -333,8 +333,8 @@ private static QueryField[] BuildAll() => // and the one services.msc answers only by opening a service and reading a tab. // // IT DECLARES A FAMILY AND THE ONE ABOVE DOES NOT, which is the whole difference - // between the two directions. This takes a call per entry: measured 236-259 ms over - // 313 services on 2026-09-05, against 423-500 ms for the entire listing. So it is + // between the two directions. This takes a call per entry: measured 148-155 ms over + // 797 entries on 2026-09-28, against 107-113 ms for the entire listing. So it is // asked for rather than always read, exactly as signatures and memory are. // // THE FIRST HOP ONLY, which a person reading a member has to know: dependents:spooler diff --git a/src/Bws.Core/Querying/QueryMembers.cs b/src/Bws.Core/Querying/QueryMembers.cs index c43d548..285e78d 100644 --- a/src/Bws.Core/Querying/QueryMembers.cs +++ b/src/Bws.Core/Querying/QueryMembers.cs @@ -127,31 +127,12 @@ public static string Without(string? text, string field, string value, bool nega } /// - /// Whether the text already carries this member, on this side. - /// - /// MEMBER BY MEMBER WHEN THE WHOLE LINE DOES NOT PARSE - UX-GUI-002, 2026-09-23. Until - /// then a mistake anywhere in the line meant no member counted, so in - /// status:running pid:abc the chip for Running went dark over its own member, and - /// clicking it wrote a second copy on the end. The whole line is still asked first, because - /// that is the reading that folds repeated fields - and a line that cannot be taken apart at - /// all, an unclosed quote, carries nothing, for the reason gives. + /// Whether the text already carries this member, on this side - for one question about one + /// text. Anything asking several questions of the same text keeps a + /// instead, which is where the answer and its argument live. /// - public static bool Carries(string? text, string field, string value, bool negated) - { - var parsed = QueryParser.Parse(text, QueryInput.BeingTyped); - - if (parsed.IsValid) - { - return parsed.Query!.Carries(field, value, negated); - } - - if (string.IsNullOrEmpty(text) || !QueryScanner.TryScan(text, out _, out var spans, out _)) - { - return false; - } - - return spans.Exists(span => Is(text[span], field, value, negated)); - } + public static bool Carries(string? text, string field, string value, bool negated) => + QueryAsTyped.Of(text).Carries(field, value, negated); /// How this member is written when a chip puts it there. public static string Member(string field, string value, bool negated) => diff --git a/src/Bws.Core/RequiredByPass.cs b/src/Bws.Core/RequiredByPass.cs index 60269b2..32489ab 100644 --- a/src/Bws.Core/RequiredByPass.cs +++ b/src/Bws.Core/RequiredByPass.cs @@ -4,10 +4,11 @@ namespace Bws.Core; /// Fills in who breaks if each entry stops. /// /// Its own pass because of one number, and it is the number in the middle. Asking the -/// manager who depends on an entry takes a call per entry - measured 2026-09-05 through the same -/// Win32 call reached from .NET, five runs with the first discarded: 236-259 ms over 313 services, -/// 784 dependents found and nothing refused. The listing this sits behind costs 423-500 ms over -/// 810 entries, so always running this would roughly double what every F5 costs, for an answer +/// manager who depends on an entry takes a call per entry - measured 2026-09-28 through this pass +/// itself, five runs with the first discarded: 148-155 ms over 797 entries, 86 of them with +/// dependents and 13 refused without rights. (The 236-259 ms over 313 services that stood here from +/// 2026-09-05 came from a separate probe.) The listing this sits behind costs 107-113 ms over the +/// same 797 entries, so always running this would more than double what every F5 costs, for an answer /// nobody had asked for. That is exactly the trade `ADR-13` refuses, and it is the same argument /// and each make with a different number. /// diff --git a/src/Bws.Core/ScmDetailReader.cs b/src/Bws.Core/ScmDetailReader.cs index 54309cc..f4f7fbd 100644 --- a/src/Bws.Core/ScmDetailReader.cs +++ b/src/Bws.Core/ScmDetailReader.cs @@ -249,7 +249,7 @@ internal static unsafe Reading ReadSidType(SafeHandle service) /// configuration already opened - this level needs no right the listing does not have. The /// whole family costs 212-223 ms over 819 entries, four warm runs, which puts it in the /// cheap pass beside the privileges rather than in the second pass of `ADR-13` beside the - /// signatures at 4620-7656 ms. + /// signatures at about 12 s of processor (2026-09-28). /// /// Translated, like the display name, so it is never an identity - `ADR-14`. The /// longest one measured is 1251 characters and two contain a newline, which is a fact for diff --git a/src/Bws.Core/ScmEntry.cs b/src/Bws.Core/ScmEntry.cs index d2a7b68..cf34672 100644 --- a/src/Bws.Core/ScmEntry.cs +++ b/src/Bws.Core/ScmEntry.cs @@ -145,10 +145,10 @@ public sealed record ScmEntry /// NOT free, which is the whole reason it is a family of its own. The declaration above /// arrives in the configuration structure the start type comes from and costs nothing. This /// takes a call PER ENTRY: the manager is asked, one service at a time, who is standing on it. - /// Measured on this machine on 2026-09-05 through the same Win32 call reached from .NET, - /// five runs with the first discarded: 236-259 ms over 313 services, 784 dependents found, - /// nothing refused. That is the size of the whole listing again - 423-500 ms over 810 - /// entries - so paying it on every F5 for a column that is off by default is exactly the trade + /// Measured on this machine on 2026-09-28 through the product's own pass, five runs with the + /// first discarded: 148-155 ms over 797 entries. That is more than the whole listing + /// again - 107-113 ms over the same entries - so paying it on every F5 for a column that is + /// off by default is exactly the trade /// `ADR-13` refuses. It is read when somebody asks, and is /// how they ask. /// @@ -236,11 +236,11 @@ public sealed record ScmEntry /// /// The first field whose ordinary state in a plain listing is /// , and the first one that earns it. Measured on a - /// real machine on 2026-08-01, seven runs with the first discarded as cold: verifying - /// 544 distinct files costs 4620-7656 ms, median 4882, against 476-551 ms for everything - /// the listing does otherwise. Reading it every time would put the listing five to eight - /// times over its one second budget - a range rather than a figure, because the spread - /// of this one operation is wider than the whole of the rest of the listing. + /// real machine on 2026-08-01, one file at a time: 4620-7656 ms over 544 files. Measured again + /// on 2026-09-28, several at a time as it now runs: about 12 s of processor over 531 files - + /// 1.0-1.2 s of clock on sixteen processors and 4.3-5.0 s on two - against 107-113 ms for + /// everything else the listing does. Reading it every time would put a listing on a + /// two-processor machine four to five times over its one second budget. /// /// That is what ADR-13 was written for. Triggers and launch paths both looked like they /// would need it and both turned out cheap enough not to - this is the family where the @@ -269,10 +269,12 @@ public sealed record ScmEntry /// /// The version claims for itself. /// - /// Cheap on its own - 0.27 s across the same 544 files - and read in the same pass - /// anyway, because it comes from a file that has just been opened for the signature. - /// Splitting it out would mean walking every binary on the machine twice to save a - /// quarter of a second on a step that already costs three. + /// Cheap on its own - 0.27 s across the same 544 files one at a time, and about 4% of the + /// pass's processor on 2026-09-28 - and read in the same pass as the signature. Not from + /// the same opening of the file, which is what this said until the external performance + /// report of 2026-09-28 read the code: each of the three answers opens the file on its own. + /// Whether the version and the hash should be a family of their own is the owner's open + /// decision, because it changes what the JSON carries - not a cost this comment can settle. /// /// Absent for a file with no version resource, which is ordinary rather than missing. /// @@ -285,10 +287,10 @@ public sealed record ScmEntry /// for the file, and this says whether it is byte for byte the same file. A binary /// swapped for another one signed by the same publisher changes this and nothing else. /// - /// Read alongside the signature rather than on its own, because both open the same file - /// and walking every binary on the machine twice would cost more than the hash does. - /// Measured on 2026-08-01: 0.52 s across 544 distinct files totalling 368 MB, against - /// 4620-7656 ms for the signatures in the same pass. + /// Read in the same pass as the signature, though NOT from the same opening of the file - each + /// answer opens it on its own, which this said the other way round until 2026-09-28. Measured on + /// 2026-08-01: 0.52 s across 544 distinct files totalling 368 MB one at a time, and on 2026-09-28 + /// about 6% of the pass's processor. /// public required Reading BinaryHash { get; init; } diff --git a/src/Bws.Core/SecondPass.cs b/src/Bws.Core/SecondPass.cs index 9ad8f58..2d9d90d 100644 --- a/src/Bws.Core/SecondPass.cs +++ b/src/Bws.Core/SecondPass.cs @@ -3,10 +3,12 @@ namespace Bws.Core; /// /// The second half of ADR-13: fills in what a listing costs too much to know up front. /// -/// The first pass reads the manager and is cheap - measured at 476-551 ms for 810 entries +/// The first pass reads the manager and is cheap - 107-113 ms for 797 entries on 2026-09-28, /// against a budget of a second. This one opens files and asks the trust providers about /// them, which measured at 4620-7656 ms over 544 distinct files before it was made to ask -/// about several at once. That gap is the entire reason the two are separate, and it is the +/// about several at once, and about 12 s of processor over 531 files after it (2026-09-28) - +/// 1.0-1.2 s of clock on sixteen processors, 4.3-5.0 s on two. That gap is the entire reason the +/// two are separate, and it is the /// first family where the separation earned itself: triggers and launch paths both turned /// out cheap enough to fold into the first pass, and this one is not. /// diff --git a/src/Bws.Core/WindowsScmCatalog.cs b/src/Bws.Core/WindowsScmCatalog.cs index 1411a9c..11a4701 100644 --- a/src/Bws.Core/WindowsScmCatalog.cs +++ b/src/Bws.Core/WindowsScmCatalog.cs @@ -73,10 +73,12 @@ public sealed partial class WindowsScmCatalog(NetworkPaths networkPaths = Networ /// old way has nothing to compare against. Nothing in the product passes it. /// /// Why this is parallel at all, measured rather than assumed. The same loop over - /// the same 810 entries costs 13-22 ms without opening a handle per entry and 455-475 ms - /// with - so about 440 ms of a listing is spent waiting on the manager, one entry at a - /// time, while fifteen processors do nothing. It is the same shape `ADR-22` already found - /// in signature verification, one layer down. + /// the same 810 entries cost 13-22 ms without opening a handle per entry and 455-475 ms + /// with, before it was parallel - so about 440 ms of a listing was spent waiting on the + /// manager, one entry at a time, while fifteen processors did nothing. It is the same shape + /// `ADR-22` already found in signature verification, one layer down. Measured again on + /// 2026-09-28 over 797 entries, with the descriptions read here since: 13.3-14.7 ms for the + /// enumeration, 721-739 ms one entry at a time and 107-113 ms as it runs. /// /// Order is held by index, not by collecting and sorting afterwards. Each entry /// writes into the slot it came from, so the answer is identical to the sequential one by @@ -301,7 +303,7 @@ private static ScmEntry Describe(SafeHandle manager, EnumeratedEntry enumerated, // Filled in by RequiredByPass, and only when asked - the OTHER direction of DependsOn // three fields up, and the reason the two sit apart. That one arrives inside this same // configuration structure and is free. This one is a call per entry, measured at - // 236-259 ms over 313 services, which is the whole listing again. + // 148-155 ms over 797 entries on 2026-09-28, which is more than the whole listing again. RequiredBy = Reading>.NotRead() }; } @@ -378,7 +380,8 @@ private static unsafe ScmConfiguration ReadConfiguration( // On this handle rather than in the second pass, and that is a measurement rather // than a convenience: the whole description family costs 212-223 ms over 819 entries, - // which is the privileges' order of magnitude and not the signatures' 4620-7656 ms. + // which is the privileges' order of magnitude and not the signatures' twelve seconds + // of processor. Description = ScmDetailReader.ReadDescription(service) }; diff --git a/src/Bws.Gui/MainWindow.Columns.cs b/src/Bws.Gui/MainWindow.Columns.cs index 028ef68..4da7565 100644 --- a/src/Bws.Gui/MainWindow.Columns.cs +++ b/src/Bws.Gui/MainWindow.Columns.cs @@ -236,7 +236,7 @@ internal bool OfferTheColumnMenu(object source) var model = new ColumnMenu( id, _columns.Choices.FirstOrDefault(choice => choice.Column.Id == id), - () => _model.QueryText, + _model.AsTyped, // The PROPERTY rather than the field, which is the same door a chip and a keystroke go // through: the setter is what parses the query, applies it and tells the list. diff --git a/src/Bws.Gui/MainWindow.HandOver.cs b/src/Bws.Gui/MainWindow.HandOver.cs index 28cca47..4baddc3 100644 --- a/src/Bws.Gui/MainWindow.HandOver.cs +++ b/src/Bws.Gui/MainWindow.HandOver.cs @@ -42,7 +42,14 @@ private async Task FirstLook() await TakeOverAsync(handed.Carried, handed.Refused).ConfigureAwait(true); } - _timer.Start(); + // ONLY IF SOMEBODY CAN SEE IT - a window started minimised reads the machine once and waits + // to be looked at, and coming back starts the timer with a tick (WatchWhileSeen). + _looked = true; + + if (Seen) + { + _timer.Start(); + } } /// diff --git a/src/Bws.Gui/MainWindow.xaml.cs b/src/Bws.Gui/MainWindow.xaml.cs index 5e59239..30b59cd 100644 --- a/src/Bws.Gui/MainWindow.xaml.cs +++ b/src/Bws.Gui/MainWindow.xaml.cs @@ -18,9 +18,10 @@ public partial class MainWindow : Window /// /// How often the list asks what is running. /// - /// Measured before choosing it: the cheap reading costs 13-22 ms over 810 entries against + /// Measured before choosing it: the cheap reading cost 13-22 ms over 810 entries against /// 423-500 ms for a full one, so asking every second spends under two per cent of one - /// processor. A slower tick would be cheaper and would also be a window that tells you + /// processor. Still true on 2026-09-28: a median of 13.6 ms over an hour of ticks on 797 + /// entries, against 107-113 ms for a full reading as it now runs. A slower tick would be cheaper and would also be a window that tells you /// about a service three seconds after you stopped it, which is the habit `A10` exists to /// break. /// @@ -40,6 +41,12 @@ public partial class MainWindow : Window private readonly DispatcherTimer _timer; + /// + /// Whether the first reading is done. Until it is, the timer is the first look's to start - + /// says why. + /// + private bool _looked; + /// /// How long the window waits after the last keystroke before it narrows the list. /// @@ -246,26 +253,16 @@ internal MainWindow(PreferencesFile preferences, MainViewModel model) // to are decided beside every other shortcut of this window. MainWindow.Suggesting.cs. WatchTheBoxForSuggestions(); - // After the window is up, not before. Reading the manager takes about half a second - // over 810 entries, and doing it in the constructor means the window appears already + // After the window is up, not before. Reading the manager takes 107-113 ms over 797 + // entries on sixteen processors (2026-09-28) and longer on fewer, and doing it in the + // constructor means the window appears already // late - the specification asks for a useful list inside a second, and part of that // second is spent showing that something is happening. Loaded += async (_, _) => await FirstLook().ConfigureAwait(true); - // Nothing to refresh when nobody can see it. A window minimised for an afternoon has - // no business asking the manager anything, and the first tick after it comes back - // catches up in one go. - IsVisibleChanged += (_, _) => - { - if (IsVisible) - { - _timer.Start(); - } - else - { - _timer.Stop(); - } - }; + // Nothing to refresh when nobody can see it - WatchWhileSeen says why both events. + IsVisibleChanged += async (_, _) => await WatchWhileSeen().ConfigureAwait(true); + StateChanged += async (_, _) => await WatchWhileSeen().ConfigureAwait(true); ListenToThePlanSheet(); @@ -327,6 +324,45 @@ private void NothingIsWatching() _model.NoLongerWanted(); } + /// + /// Whether anybody can see the list - shown and not minimised. + /// + private bool Seen => IsVisible && WindowState != WindowState.Minimized; + + /// + /// The once-a-second reading runs while the window is seen and stops while it is not. A window + /// minimised for an afternoon has no business asking the manager anything - and on a machine + /// whose list of entries changed, a tick does a full reading, second phase included. + /// + /// TWO EVENTS, AND UNTIL 2026-09-28 THERE WAS ONE - backlog 467. Only IsVisibleChanged + /// stopped the timer, and WPF leaves IsVisible true on a minimised window, so the comment here + /// promised a rest the window never took: measured with tools/perf-probe/window-idle.ps1 + /// -Minimised, 375 ms of processor in 36 s minimised by hand and 203 ms in 39 s started minimised. + /// + /// Coming back asks at once rather than a second later, because the list on screen is what + /// the machine looked like when the window went away. Not before the first reading, which + /// starts the timer itself when it is done: a tick there would begin a full reading of its own + /// and the first look, finding a reading out, would return without sorting what arrives. + /// + private async Task WatchWhileSeen() + { + if (!Seen) + { + _timer.Stop(); + + return; + } + + if (!_looked || _timer.IsEnabled) + { + return; + } + + _timer.Start(); + + await Tick().ConfigureAwait(true); + } + private async Task Tick() { // NOT WHILE SOMEBODY IS MID-WORD, AND THIS IS A MEASUREMENT RATHER THAN A COURTESY. On the diff --git a/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs b/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs index 3567cf6..900a7b5 100644 --- a/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs +++ b/src/Bws.Gui/ViewModels/Catalogue.Specimens.cs @@ -2,6 +2,7 @@ using System.Windows.Controls; using Bws.Core; using Bws.Core.Planning; +using Bws.Core.Querying; namespace Bws.Gui.ViewModels; @@ -185,8 +186,12 @@ private static (object Normal, object Extreme)? TemplateSpecimens(string key) } /// A row of chips lit by a query, the way the filter row lights them. - private static IReadOnlyList Chips(string query, string field, params (string Key, string Value)[] members) => - [.. members.Select(member => new FilterChip(member.Key, field, member.Value, negated: false, () => query, _ => { }))]; + private static IReadOnlyList Chips(string query, string field, params (string Key, string Value)[] members) + { + var typed = QueryAsTyped.Of(query); + + return [.. members.Select(member => new FilterChip(member.Key, field, member.Value, negated: false, () => typed, _ => { }))]; + } /// /// The keyed templates of the theme files, each drawn over its specimen - one group per file, diff --git a/src/Bws.Gui/ViewModels/CellOrder.cs b/src/Bws.Gui/ViewModels/CellOrder.cs index 8abf14f..6b0e29c 100644 --- a/src/Bws.Gui/ViewModels/CellOrder.cs +++ b/src/Bws.Gui/ViewModels/CellOrder.cs @@ -24,7 +24,9 @@ internal sealed class CellOrder(Column column, bool ascending) : System.Collecti /// listing - a few hundred. A new comparer is built for every sort, so nothing here outlives /// the order it belongs to. It does hold its rows alive until then, and that is deliberate /// rather than overlooked: the rows are held anyway by , which keeps one - /// object per service for as long as the service exists. + /// object per service for as long as the service exists. The one exception is a service + /// removed while this is the sort - its row stays here until the next sort, one small object per + /// removed service (G-14 of the external performance report, 2026-09-28, judged negligible). /// /// Not thread safe, and it does not need to be. A comparer handed to a ListCollectionView is /// only ever called from the thread that owns the view, which is the interface thread. diff --git a/src/Bws.Gui/ViewModels/Chosen.cs b/src/Bws.Gui/ViewModels/Chosen.cs index 0dc970b..c7d30be 100644 --- a/src/Bws.Gui/ViewModels/Chosen.cs +++ b/src/Bws.Gui/ViewModels/Chosen.cs @@ -321,6 +321,18 @@ private void Stop() private void Moved(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { + // ONLY WHAT THE PANEL SHOWS - G-8 of the external performance report of 2026-09-28. A row + // raises its shapes and its highlight beside the word for every cell, and the highlight goes + // out again three seconds later, so one service starting rebuilt the whole panel up to four + // times. Every line of the panel is read from the entry, which only ever changes with the + // word for every cell - and the name at the head changes with its own. An empty name means + // everything changed, by the framework's own convention, so it rebuilds too. + if (!string.IsNullOrEmpty(e.PropertyName) + && e.PropertyName is not (EntryRow.EveryCell or nameof(EntryRow.DisplayName))) + { + return; + } + Rebuild(); // THE ROW MOVED, AND A FULL READING MAY HAVE BEEN WHAT MOVED IT - which hands the row a fresh diff --git a/src/Bws.Gui/ViewModels/ColumnMenu.cs b/src/Bws.Gui/ViewModels/ColumnMenu.cs index 74dfc02..6438e88 100644 --- a/src/Bws.Gui/ViewModels/ColumnMenu.cs +++ b/src/Bws.Gui/ViewModels/ColumnMenu.cs @@ -34,7 +34,7 @@ public sealed class ColumnMenu { private readonly ColumnChoice? _choice; - internal ColumnMenu(string columnId, ColumnChoice? choice, Func read, Action write) + internal ColumnMenu(string columnId, ColumnChoice? choice, Func read, Action write) { ArgumentNullException.ThrowIfNull(columnId); diff --git a/src/Bws.Gui/ViewModels/Columns.cs b/src/Bws.Gui/ViewModels/Columns.cs index 436c340..c7a2369 100644 --- a/src/Bws.Gui/ViewModels/Columns.cs +++ b/src/Bws.Gui/ViewModels/Columns.cs @@ -315,7 +315,8 @@ internal static partial class Columns ShownAtFirst = false, // The same family as the signature and the version beside it, because one pass fills - // all three - SecondPass reads the file once and writes the three answers it found. + // all three - SecondPass asks for the three answers of each file together. NOT from one + // opening of the file, which this said until 2026-09-28: each answer opens it on its own. Needs = ExtraRead.Signatures, Reads = entry => CellFaces.Say(entry.BinaryHash, value => value), Outcome = entry => entry.BinaryHash.Outcome @@ -419,8 +420,8 @@ internal static partial class Columns // something. "What needs this" is asked before stopping something, and it is the only // one of the two that can talk somebody out of an action. // - // IT IS THE ONLY COLUMN IN THIS CATALOGUE THAT COSTS A CALL PER ENTRY - 236-259 ms - // over 313 services, measured 2026-09-05, against 423-500 ms for the whole listing. So + // IT IS THE ONLY COLUMN IN THIS CATALOGUE THAT COSTS A CALL PER ENTRY - 148-155 ms + // over 797 entries, measured 2026-09-28, against 107-113 ms for the whole listing. So // it declares a family and is read when somebody turns it on, exactly as the four // signature columns and the memory column are. Id = "requiredBy", diff --git a/src/Bws.Gui/ViewModels/EntryRow.cs b/src/Bws.Gui/ViewModels/EntryRow.cs index d83f4d7..a0660dd 100644 --- a/src/Bws.Gui/ViewModels/EntryRow.cs +++ b/src/Bws.Gui/ViewModels/EntryRow.cs @@ -221,7 +221,7 @@ internal void StandsAlsoFor(IReadOnlyList instances) /// than a value: a computed cell answers correctly whenever it is asked, so asking it proves /// nothing about whether anybody was told to ask. /// - private const string EveryCell = "Item[]"; + internal const string EveryCell = "Item[]"; /// When this row last moved, for whoever is clearing the highlight. internal DateTimeOffset ChangedAt { get; private set; } @@ -297,14 +297,19 @@ internal bool Absorb(ScmEntry entry, DateTimeOffset now) { var qualifies = StartQualifiers.Of(entry); + // Worked out once and used twice - to decide whether the row moved and then to show it. + // Until 2026-09-28 each was worked out again below (G-11 of the external performance report). + var startType = CellFaces.StartLabel(entry, qualifies); + var account = SystemAccounts.Shown(entry.Account); + // Compared against what the row currently SHOWS rather than against the entry behind // it. The two are the same thing said twice, and the shown form is the one that now // carries the qualifiers - so a file going missing under a service counts as the row // moving, which it is. var moved = _entry.Status != entry.Status || !SameProcess(_entry.ProcessId, entry.ProcessId) - || CellFaces.StartLabel(entry, qualifies) != _startType - || SystemAccounts.Shown(entry.Account) != _account + || startType != _startType + || account != _account || _entry.DisplayName != entry.DisplayName; _entry = entry; @@ -314,8 +319,8 @@ internal bool Absorb(ScmEntry entry, DateTimeOffset now) StartShape = CellFaces.StartShape(entry, qualifies); AgainstShape = CellFaces.AgainstShape(entry.RunsAgainstItsStartType); - _startType = CellFaces.StartLabel(entry, qualifies); - _account = SystemAccounts.Shown(entry.Account); + _startType = startType; + _account = account; // UNCONDITIONAL, AND THAT IS NOT LAZINESS - IT IS THE ONLY HONEST ANSWER HERE. The five // comparisons above decide whether the row MOVED, which is a question about what a person diff --git a/src/Bws.Gui/ViewModels/FilterChips.cs b/src/Bws.Gui/ViewModels/FilterChips.cs index 6a0f681..6eb42d4 100644 --- a/src/Bws.Gui/ViewModels/FilterChips.cs +++ b/src/Bws.Gui/ViewModels/FilterChips.cs @@ -23,18 +23,22 @@ namespace Bws.Gui.ViewModels; /// public sealed class FilterChip : Observable { - private readonly Func _read; + // THE QUERY AS TYPED, NOT THE TEXT, since 2026-09-28 - G-4 of the external performance report. + // Sixteen chips each parsing the same line on every keystroke was sixteen of the eighteen parses + // one keystroke cost. They still hold nothing: what they read is kept by whoever owns the text, + // beside the text and replaced when it differs, so it is the same fact and not a second copy. + private readonly Func _read; private readonly Action _write; private readonly Func _label; internal FilterChip( - string labelKey, string field, string value, bool negated, Func read, Action write) + string labelKey, string field, string value, bool negated, Func read, Action write) : this(() => Texts.Of(labelKey), field, value, negated, read, write) { } private FilterChip( - Func label, string field, string value, bool negated, Func read, Action write) + Func label, string field, string value, bool negated, Func read, Action write) { _label = label; _read = read; @@ -64,7 +68,7 @@ private FilterChip( /// ORed by the language - so ticking two shows both, which is what a person expects from a /// list of ticks. Excluding is a different gesture and does not have one yet. /// - internal static FilterChip Spelled(string field, string value, Func read, Action write) => + internal static FilterChip Spelled(string field, string value, Func read, Action write) => new(() => value, field, value, negated: false, read, write); /// The field this chip constrains, in the language's own spelling. @@ -100,13 +104,15 @@ internal static FilterChip Spelled(string field, string value, Func read /// public bool IsOn { - get => QueryMembers.Carries(_read(), Field, Value, Negated); + get => _read().Carries(Field, Value, Negated); set { + var text = _read().Text; + _write(value - ? QueryMembers.With(_read(), Field, Value, Negated) - : QueryMembers.Without(_read(), Field, Value, Negated)); + ? QueryMembers.With(text, Field, Value, Negated) + : QueryMembers.Without(text, Field, Value, Negated)); Rethink(); } @@ -118,12 +124,12 @@ public bool IsOn /// Called for every chip whenever the text changes, because one edit can move several of /// them - clearing the box turns them all off at once, and a chip that only listened to its /// own click would stay lit over a query that no longer says anything about it. + /// + /// The label is not raised, since 2026-09-28 - it cannot change while the window runs + /// (there is no switching of language in flight), and raising it made every binding to it read + /// the text catalogue again on every keystroke for sixteen chips. /// - internal void Rethink() - { - Raise(nameof(IsOn)); - Raise(nameof(Label)); - } + internal void Rethink() => Raise(nameof(IsOn)); } /// @@ -200,7 +206,7 @@ public sealed class FilterBar : Observable { private readonly IReadOnlyList _chips; - internal FilterBar(Func read, Action write) + internal FilterBar(Func read, Action write) { Groups = FilterChips.Grouped(read, write); _chips = [.. Groups.SelectMany(group => group.Chips)]; @@ -261,7 +267,7 @@ internal static class FilterChips /// Adding the field is a change to a surface people write scripts against, which is a decision /// rather than a slice of this one. /// - internal static IReadOnlyList Grouped(Func read, Action write) => + internal static IReadOnlyList Grouped(Func read, Action write) => [ new FilterGroup("gui.filter.group.state", [ diff --git a/src/Bws.Gui/ViewModels/MainViewModel.cs b/src/Bws.Gui/ViewModels/MainViewModel.cs index 0801090..c2f5606 100644 --- a/src/Bws.Gui/ViewModels/MainViewModel.cs +++ b/src/Bws.Gui/ViewModels/MainViewModel.cs @@ -6,8 +6,9 @@ namespace Bws.Gui.ViewModels; /// /// What the window shows and how it got there. /// -/// Every reading runs off the interface thread, because a full one takes about half a second -/// over 810 entries and a window that stops answering for half a second looks broken. +/// Every reading runs off the interface thread, because a full one takes 107-113 ms over 797 +/// entries on sixteen processors and 215-234 ms cold on eight (2026-09-28), a second phase takes +/// seconds, and a window that stops answering for that long looks broken. /// `docs/06`, part 4: nothing from a worker thread touches the interface, so what comes back /// is a plain list and everything the window binds to is built here. /// @@ -67,6 +68,14 @@ public sealed partial class MainViewModel : Checked /// private string _queryText = string.Empty; + /// + /// The text above, parsed once for everything that asks about it - . + /// + private QueryAsTyped _asTyped = QueryAsTyped.Of(string.Empty); + + /// The answer on screen and how many copies it folded - . + private (Narrowed Narrowed, int Instances)? _answered; + /// /// The controls standing for members of the query. Declared after the text they read, /// because they close over it. @@ -122,7 +131,7 @@ public MainViewModel( // for the same reason and a second one: TWO things can ask the second phase for something // and one of them is the picker, which the window builds after this - MainViewModel.Asking. _readings = new Readings( - catalog, _index, () => Says, Reread, TellTheList, inspector, reader, () => Asked); + catalog, _index, () => Says, Reread, TellTheList, SayTheAnswer, inspector, reader, () => Asked); // THE PANEL READS WHAT ITS ENTRY LACKS, THROUGH THE SAME READINGS - UX-GUI-005. After them, // because it is handed them. @@ -133,7 +142,7 @@ public MainViewModel( // the query, applies it and tells the list, so a chip goes through the same door a // keystroke does. Writing the field instead would change the box and leave the list // showing the answer to the previous question. - _filters = new FilterBar(() => _queryText, text => QueryText = text); + _filters = new FilterBar(AsTyped, text => QueryText = text); // After the chips, because it reads their labels for the sentence beside a value - the // same word somebody sees in the row above the list, in the same language. The examples @@ -358,7 +367,41 @@ private void TellTheList() => _index.Ordered.Count, _scoping.InScope.Count, _scoping.Current, - Scopes.AsksElsewhere(_scoping.Current, _queryText)); + Scopes.AsksElsewhere(_scoping.Current, AsTyped())); + + /// + /// The query in the box, parsed once for everything that asks about it - the narrowing, the + /// sentence about the scope, the sixteen chips and an open column menu. G-4 of the external + /// performance report of 2026-09-28 counted eighteen parses of one text on one keystroke. + /// + /// KEPT BY THE TEXT, NOT BY THE MOMENT, which is what keeps it from being a second copy of + /// the query: it is made again whenever the text differs from the one it was made from, and it + /// does not matter who asks first or whether anybody remembered to refresh it. + /// + internal QueryAsTyped AsTyped() => + string.Equals(_asTyped.Text, _queryText, StringComparison.Ordinal) + ? _asTyped + : _asTyped = QueryAsTyped.Of(_queryText); + + /// + /// The sentence about the answer on screen, said from the last answer drew. + /// + /// Also by the second pass setting out, since 2026-09-28 - G-11 of the external performance + /// report. That changes whether the list is still being filled and nothing else, and it used to + /// be said by recutting the scope and running the query again over entries shown a moment before. + /// + /// Silent after a query with a mistake in it, exactly as Apply is: Apply leaves before this + /// sentence then, so the end of the pass would never take back a "still filling" said here. + /// + private void SayTheAnswer() + { + if (_answered is { } answered) + { + Says.AboutTheAnswer( + Asked, _holding.Pending, answered.Narrowed, + _readings.Have, _readings.Filling, answered.Instances, !_showingOverview); + } + } /// /// Reads the query and narrows the list to what it selects. @@ -374,8 +417,7 @@ private void Apply() // somebody making a mistake - and a box that shows an error through most of the typing // teaches people to ignore the box. The command line says Finished, because by the time // text reaches it there is no later. - var parsed = QueryParser.Parse( - _queryText, input: QueryInput.BeingTyped); + var parsed = AsTyped().Parsed; if (!parsed.IsValid) { @@ -383,6 +425,7 @@ private void Apply() // somebody is typing, and fixing one to be told about the next is a poor trade for // a shorter line. AboutTheQuery(string.Join(" ", parsed.Problems.Select(QueryMessages.Of))); + _answered = null; // The chips read the TEXT, so they follow it even though the list does not - UX-GUI-002. // Leaving before this kept Running and Manual lit over `stat:runing`, a query that was @@ -442,9 +485,8 @@ private void Apply() // The last argument is which screen has the middle of the window, and only one sentence // under there asks about it - backlog 263. Passed rather than read out of this class by // Sentences, because that class has never been allowed to know a window exists. - Says.AboutTheAnswer( - Asked, _holding.Pending, narrowed, - _readings.Have, _readings.Filling, rolled.Instances, !_showingOverview); + _answered = (narrowed, rolled.Instances); + SayTheAnswer(); TellTheList(); diff --git a/src/Bws.Gui/ViewModels/Narrowing.cs b/src/Bws.Gui/ViewModels/Narrowing.cs index 832d0d0..555d8f9 100644 --- a/src/Bws.Gui/ViewModels/Narrowing.cs +++ b/src/Bws.Gui/ViewModels/Narrowing.cs @@ -37,6 +37,11 @@ internal static class Narrowing /// compiled once and never reaches out to the system while it is being asked. The budget is /// 50 ms over the whole listing, from section 8.1 of the specification, and the measurement /// against it is 0.42-2.25 ms over 810 entries. + /// + /// THAT IS THIS METHOD, NOT THE KEYSTROKE. The whole reaction in the window was 143 ms + /// on 2026-08-11 and 184-228 ms for a query leaving most rows, and nearly all of it is the + /// list's own reaction to the change (backlog 153) - so the budget is met here and not on + /// screen, and a number from this method is no answer to "is typing fast enough". /// public static Narrowed Of(Query query, IReadOnlyList everything) { diff --git a/src/Bws.Gui/ViewModels/Planned.Running.cs b/src/Bws.Gui/ViewModels/Planned.Running.cs index a54e150..2f4262a 100644 --- a/src/Bws.Gui/ViewModels/Planned.Running.cs +++ b/src/Bws.Gui/ViewModels/Planned.Running.cs @@ -122,7 +122,7 @@ internal void Announce(PlanStep step, int number) _step = Texts.Of( "gui.plan.progress", number, - _plan?.Steps.Count() ?? 0, + _stepsInPlan, PlanWords.Word(step.Operation), step.ServiceName); diff --git a/src/Bws.Gui/ViewModels/Planned.cs b/src/Bws.Gui/ViewModels/Planned.cs index c294f9f..1bb69f1 100644 --- a/src/Bws.Gui/ViewModels/Planned.cs +++ b/src/Bws.Gui/ViewModels/Planned.cs @@ -62,6 +62,9 @@ public sealed partial class Planned : Checked private BulkPlan? _plan; private BulkRun? _run; + /// How many steps the plan on screen has, counted once when it is shown rather than on every step of a run. + private int _stepsInPlan; + /// /// What a person calls the one entry this plan is about, handed over by whoever is looking at /// rows. Empty whenever nobody knew one, which the title and both read. @@ -253,11 +256,35 @@ private static NamedSentence Line(PlanStep step, int number) => /// /// Lines rather than strings since 2026-09-24, because one of them can carry the offer to /// stop the entry too - says why, and why never on a record. + /// + /// Built once for each plan and each state of the offer, since 2026-09-28 - G-7 of the + /// external performance report counted three builds on every showing, one for each binding that + /// asks. Kept beside the plan and the offer it was built for, so it cannot outlive either. /// - public IReadOnlyList Warnings => _plan is not { } plan - ? [] - : PlanWarningLine.Of( - [.. plan.Warnings.Where(warning => !Heavy(warning))], offering: _run is null && !Busy); + public IReadOnlyList Warnings + { + get + { + if (_plan is not { } plan) + { + return []; + } + + var offering = _run is null && !Busy; + + if (_warnings is not { } kept || !ReferenceEquals(kept.Plan, plan) || kept.Offered != offering) + { + kept = (plan, offering, PlanWarningLine.Of([.. plan.Warnings.Where(warning => !Heavy(warning))], offering)); + _warnings = kept; + } + + return kept.Lines; + } + } + + // "Offered" rather than "Offering" for the element, which DeadCodeGuards reads by name and would + // take for a caller of ActionBar.Offering. + private (BulkPlan Plan, bool Offered, IReadOnlyList Lines)? _warnings; /// /// The entries that get no plan at all, and why. @@ -281,8 +308,11 @@ private static NamedSentence Line(PlanStep step, int number) => /// Rendered by the core, so these are commands this tool really accepts rather than text that /// looks like them - a guard in the command line's own tests holds that, and it caught a switch /// rendered onto a verb that refuses it. + /// + /// Rendered once, when the plan is shown, since 2026-09-28 - G-7 of the external + /// performance report: four bindings read this, and each read rendered every command again. /// - public IReadOnlyList Commands => _plan is not { } plan ? [] : EquivalentCommand.For(plan); + public IReadOnlyList Commands { get; private set; } = []; /// /// Where this panel is in the only sequence it has: nothing done, doing it, done. @@ -384,6 +414,8 @@ internal bool Show(BulkPlan plan, string? shownAs = null, string? because = null // could have: a report of what happened to five services, sitting under the steps of a plan // for five different ones, with nothing on screen to say the two do not belong together. _plan = plan; + _stepsInPlan = plan.Steps.Count(); + Commands = EquivalentCommand.For(plan); _run = null; Busy = false; Progress = string.Empty; @@ -422,6 +454,9 @@ internal bool Hide() } _plan = null; + _stepsInPlan = 0; + Commands = []; + _warnings = null; _run = null; _shownAs = string.Empty; _because = string.Empty; diff --git a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs index 7cd39b9..9ff2e71 100644 --- a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs +++ b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs @@ -8,8 +8,8 @@ namespace Bws.Gui.ViewModels; /// /// WHY THE PANEL READS AT ALL, and the number decided it. Until 2026-09-24 the panel showed /// "not read" beside the signature, the memory and who depends on an entry, and sent the reader to -/// turn on a column - which reads every entry on the machine, 4620-7656 ms for the signatures over -/// 810. The owner's rule was that the panel reads for itself if the cost allows, and the cost was +/// turn on a column - which reads every entry on the machine, about 12 s of processor for the +/// signatures over 797 (2026-09-28). The owner's rule was that the panel reads for itself if the cost allows, and the cost was /// measured before anything was built (tools/details-probe, 797 entries, five counted runs, each in /// a fresh process): the three families for one typical service 46.7-66.9 ms the first time a /// process asks, the signature family 6-13 ms every time after, and 425-518 ms for the one 98 MB diff --git a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs index 7915413..d603176 100644 --- a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs +++ b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs @@ -75,7 +75,9 @@ private async Task FillAsync(IReadOnlyList entries) // Said before the work rather than after it, or the one state this announces would be // announced only once it had stopped being true - the same argument as the reading above. - _settled(); + // The sentence only, since 2026-09-28: the entries have not moved since the reading that + // handed them here narrowed and showed them (G-11, _announce). + _announce(); IReadOnlyList filled; @@ -170,9 +172,10 @@ private IReadOnlyList Fill(IReadOnlyList entries, ExtraRead filled = MemoryPass.Fill(filled, _reader!); } - // THE THIRD FAMILY, 2026-09-06, and it sits between the other two in price: 236-259 ms over - // 313 services against under a millisecond for memory and seven and a half seconds for - // signatures. Last because it is the only one that goes back to the manager, so a run that + // THE THIRD FAMILY, 2026-09-06, and it sits between the other two in price: 148-155 ms over + // 797 entries against under a millisecond for memory and about twelve seconds of processor + // for signatures (2026-09-28). Last because it is the only one that goes back to the + // manager, so a run that // wants all three has already finished with the files and the processes by the time it // starts walking services one at a time. if (wanted.HasFlag(ExtraRead.RequiredBy)) @@ -227,7 +230,8 @@ private IReadOnlyList Fill(IReadOnlyList entries, ExtraRead /// Answered by reading the machine again rather than by filling in what is held. The /// entries kept from the last full reading are older than the rows on screen - a tick has been /// writing statuses into them since - so absorbing them would roll those changes back. Reading - /// again costs about half a second on top of a pass that costs seconds, and it is the + /// again costs about a tenth of a second on sixteen processors on top of a pass that costs + /// seconds, and it is the /// difference between a fresh answer and a stale one. /// internal bool WantsMore() => Asked(); diff --git a/src/Bws.Gui/ViewModels/Readings.cs b/src/Bws.Gui/ViewModels/Readings.cs index 3a5a498..1fb0348 100644 --- a/src/Bws.Gui/ViewModels/Readings.cs +++ b/src/Bws.Gui/ViewModels/Readings.cs @@ -83,6 +83,15 @@ internal sealed partial class Readings /// private readonly Action _changed; + /// + /// That the second pass is setting out, which changes one sentence and nothing else. + /// + /// Its own callback since 2026-09-28 - G-11 of the external performance report. The pass + /// used to announce itself through , which recuts the scope and runs the + /// query again over entries the reading just before it had already narrowed and shown. + /// + private readonly Action _announce; + /// /// Whether the window this reads for has gone. /// @@ -126,6 +135,7 @@ internal Readings( Func look, Action settled, Action changed, + Action announce, IBinaryInspector? inspector = null, IProcessMemoryReader? reader = null, Func? wanted = null) @@ -138,6 +148,7 @@ internal Readings( _look = look; _settled = settled; _changed = changed; + _announce = announce; } /// Whether the last reading failed outright. @@ -258,8 +269,9 @@ private async Task LoadEverything() /// none of them, and a test can call it whenever it likes instead of waiting for seconds /// to pass. /// - /// The cheap reading measures 13-22 ms over 810 entries against 423-500 ms for a full one, - /// which is what makes asking once a second reasonable rather than rude. + /// The cheap reading measured 12.5-16.9 ms (p95) over 797 entries across an hour of ticks on + /// 2026-09-28, against 107-113 ms for a full one, which is what makes asking once a second + /// reasonable rather than rude. /// internal async Task RefreshAsync() { diff --git a/src/Bws.Gui/ViewModels/RowIndex.cs b/src/Bws.Gui/ViewModels/RowIndex.cs index 69a1188..66d187d 100644 --- a/src/Bws.Gui/ViewModels/RowIndex.cs +++ b/src/Bws.Gui/ViewModels/RowIndex.cs @@ -111,7 +111,10 @@ public Freshening Absorb(IReadOnlyList statuses) moved |= _byName[status.ServiceName].Absorb(status, now); } - Fade(); + // NO FADE HERE SINCE 2026-09-28, which is where it stood beside the window's own on every + // tick - G-14 of the external performance report, two walks over every row a second for one + // job. The window's tick keeps the other one because it covers more: a tick dropped while a + // reading is out never reaches this method, and a highlight must still go out on time. // Only when something moved. Re-running the filter over 810 entries every second to // find out that nothing changed would be the one part of this that is genuinely @@ -163,7 +166,8 @@ public void Fade() /// What a cheap reading found, and therefore what the window owes. /// /// Three answers rather than a boolean, because the two that are not "nothing" are owed very -/// different things - a filter re-run costs milliseconds and a full reading costs half a second. +/// different things - a filter re-run costs milliseconds and a full reading about a tenth of a +/// second, with seconds more when a second phase follows it. /// Returned rather than acted on, so the class that knows about queries stays the class that /// decides what to do about them. /// diff --git a/src/Bws.Gui/ViewModels/RowList.cs b/src/Bws.Gui/ViewModels/RowList.cs index 0b93228..7c477f0 100644 --- a/src/Bws.Gui/ViewModels/RowList.cs +++ b/src/Bws.Gui/ViewModels/RowList.cs @@ -168,6 +168,17 @@ public void Reconcile(IReadOnlyList wanted) return; } + // ONE RESET INSTEAD OF HUNDREDS OF REMOVALS WAS TRIED HERE AND DID NOTHING - backlog 153, + // measured 2026-08-11. Swapping 764 separate removals for one reset left the keystroke at the + // same 143 ms in the window, because this method and the filter together are under 3 ms and + // about 140 ms is the list's own reaction to any change at all. It was taken back, since it + // also broke the promise above about selection and scroll for no measured gain. Written here + // because the idea is obvious from this code alone - the external performance report of + // 2026-09-28 proposed it again for that reason. What is still open is where the 140 ms goes. + + // BUILT ON EVERY CALL, the tick included - unlike `present` below, which waits until + // something is out of place. About eight hundred hash insertions a second on a quiet machine + // (G-14 of the same report), judged too small to be worth a second code path. var keeping = new HashSet(wanted); for (var index = Count - 1; index >= 0; index--) diff --git a/src/Bws.Gui/ViewModels/Says.cs b/src/Bws.Gui/ViewModels/Says.cs index 9a07519..5d998c5 100644 --- a/src/Bws.Gui/ViewModels/Says.cs +++ b/src/Bws.Gui/ViewModels/Says.cs @@ -350,7 +350,7 @@ internal void Did(string sentence) /// The quieter of the two, and second in the order for that reason. An action's refusal is /// about what just failed - newer news than a file read before the window appeared. (A query /// problem stood in this order too until 2026-09-23, and has a line of its own now.) It outlives the first tick - /// on purpose: the reading finishes in half a second, and a sentence gone by then is a + /// on purpose: the reading finishes in a fraction of a second, and a sentence gone by then is a /// sentence nobody was given. /// /// Rule 8 in the one place a window can break it without anything looking wrong. A layout half diff --git a/src/Bws.Gui/ViewModels/Scopes.cs b/src/Bws.Gui/ViewModels/Scopes.cs index 947694c..91cf388 100644 --- a/src/Bws.Gui/ViewModels/Scopes.cs +++ b/src/Bws.Gui/ViewModels/Scopes.cs @@ -221,10 +221,10 @@ public static class Scopes /// method which of the six type values belong to which scope, and that is a second definition /// of what a driver is - the one thing the rest of this file refuses to write down twice. /// - internal static bool AsksElsewhere(EntryScope scope, string query) => scope switch + internal static bool AsksElsewhere(EntryScope scope, QueryAsTyped query) => scope switch { - EntryScope.Services => QueryMembers.Carries(query, DriverField, DriverValue, negated: false), - EntryScope.Drivers => QueryMembers.Carries(query, DriverField, DriverValue, negated: true), + EntryScope.Services => query.Carries(DriverField, DriverValue, negated: false), + EntryScope.Drivers => query.Carries(DriverField, DriverValue, negated: true), // Everything leaves nothing out, so nothing can be asked for elsewhere. EntryScope.Everything => false, diff --git a/tests/Bws.Architecture.Tests/ShapeCeilings.cs b/tests/Bws.Architecture.Tests/ShapeCeilings.cs index ba4b2ac..d8f7e88 100644 --- a/tests/Bws.Architecture.Tests/ShapeCeilings.cs +++ b/tests/Bws.Architecture.Tests/ShapeCeilings.cs @@ -81,11 +81,13 @@ internal static class ShapeCeilings internal const int SignaturesNearWidest = 3; /// - /// 79 members with code in them, the plan's view model Planned across six partial files. - /// MainWindow is second at 63 across eleven, and those two are the whole band. Neither was ever - /// near a file ceiling, which is the point of measuring the type rather than the file. + /// 78 members with code in them, MainWindow across fourteen partial files. Lowered from 79 on + /// 2026-09-28, when the plan's view model Planned - the largest until then - lost one (its + /// commands became a value set once per plan, G-7 of the external performance report) and the + /// window's own count stood at 78. Neither was ever near a file ceiling, which is the point of + /// measuring the type rather than the file. /// - internal const int MostMethodsInType = 79; + internal const int MostMethodsInType = 78; /// Two at 56 or more: Planned and MainWindow. internal const int TypesNearMostMethods = 2; diff --git a/tests/Bws.Gui.Tests/ChosenGuards.cs b/tests/Bws.Gui.Tests/ChosenGuards.cs index 4e245e4..d16be17 100644 --- a/tests/Bws.Gui.Tests/ChosenGuards.cs +++ b/tests/Bws.Gui.Tests/ChosenGuards.cs @@ -134,6 +134,39 @@ public void The_panel_moves_when_the_row_moves() Assert.NotEqual(before, Says(chosen, "gui.column.account")); } + /// + /// A service starting rebuilds the panel ONCE, and its highlight going out three seconds later + /// does not rebuild it at all - G-8 of the external performance report of 2026-09-28. A row + /// raises its shapes and its highlight beside the word for every cell, and the panel used to + /// rebuild all its sections for each of them. + /// + [Fact] + public void A_status_moving_rebuilds_the_panel_once_and_the_highlight_going_out_does_not() + { + var row = EntryRow.Of(Rows.Entry("Spooler")); + var chosen = new Chosen { Row = row }; + + chosen.Show(); + + var rebuilt = 0; + + chosen.PropertyChanged += (_, changed) => + { + if (changed.PropertyName == nameof(Chosen.Sections)) + { + rebuilt++; + } + }; + + Assert.True(row.Absorb(new ScmStatus("Spooler", EntryStatus.Stopped, Reading.Absent()), DateTimeOffset.UnixEpoch)); + Assert.True(row.RecentlyChanged); + Assert.Equal(1, rebuilt); + + row.RecentlyChanged = false; + + Assert.Equal(1, rebuilt); + } + /// /// A closed panel stops listening, which is the teardown this class would leak through. /// diff --git a/tests/Bws.Gui.Tests/ColumnMenuGuards.cs b/tests/Bws.Gui.Tests/ColumnMenuGuards.cs index 86f3abc..0579e13 100644 --- a/tests/Bws.Gui.Tests/ColumnMenuGuards.cs +++ b/tests/Bws.Gui.Tests/ColumnMenuGuards.cs @@ -74,7 +74,7 @@ public void Every_column_that_names_a_query_field_names_one_the_language_has() public void Ticking_a_value_writes_the_member_into_the_query_and_unticking_takes_it_out() { var text = string.Empty; - var menu = new ColumnMenu("status", null, () => text, written => text = written); + var menu = new ColumnMenu("status", null, () => QueryAsTyped.Of(text), written => text = written); Assert.NotEmpty(menu.Values); @@ -107,7 +107,7 @@ public void Ticking_a_value_writes_the_member_into_the_query_and_unticking_takes public void A_tick_is_lit_by_the_query_text_rather_than_by_having_been_clicked() { var text = "status:stopped"; - var menu = new ColumnMenu("status", null, () => text, written => text = written); + var menu = new ColumnMenu("status", null, () => QueryAsTyped.Of(text), written => text = written); Assert.True(menu.Values.Single(value => value.Label == "stopped").IsOn); Assert.False(menu.Values.Single(value => value.Label == "running").IsOn); @@ -137,7 +137,7 @@ public void A_column_with_nothing_to_offer_still_offers_the_way_to_put_it_away() foreach (var id in noField.Concat(freeText)) { - var menu = new ColumnMenu(id, null, () => text, written => text = written); + var menu = new ColumnMenu(id, null, () => QueryAsTyped.Of(text), written => text = written); Assert.Empty(menu.Values); Assert.NotEmpty(menu.HideLabel); @@ -146,7 +146,7 @@ public void A_column_with_nothing_to_offer_still_offers_the_way_to_put_it_away() // And the ones that DO have something, so the two lists above are not simply everything. foreach (var id in new[] { "status", "startType", "entryType", "signature", "triggers" }) { - Assert.NotEmpty(new ColumnMenu(id, null, () => text, written => text = written).Values); + Assert.NotEmpty(new ColumnMenu(id, null, () => QueryAsTyped.Of(text), written => text = written).Values); } } @@ -161,7 +161,7 @@ public void A_column_is_put_away_through_the_pickers_own_choice_and_the_last_one var text = string.Empty; var first = bar.Choices.First(choice => choice.IsShown); - var menu = new ColumnMenu(first.Column.Id, first, () => text, written => text = written); + var menu = new ColumnMenu(first.Column.Id, first, () => QueryAsTyped.Of(text), written => text = written); Assert.True(menu.MayHide); @@ -177,7 +177,7 @@ public void A_column_is_put_away_through_the_pickers_own_choice_and_the_last_one } var alone = bar.Choices.Single(one => one.IsShown); - var last = new ColumnMenu(alone.Column.Id, alone, () => text, written => text = written); + var last = new ColumnMenu(alone.Column.Id, alone, () => QueryAsTyped.Of(text), written => text = written); Assert.False(last.MayHide); Assert.NotEmpty(last.HideRefused); diff --git a/tests/Bws.Gui.Tests/MinimisedWindowGuards.cs b/tests/Bws.Gui.Tests/MinimisedWindowGuards.cs new file mode 100644 index 0000000..cd32004 --- /dev/null +++ b/tests/Bws.Gui.Tests/MinimisedWindowGuards.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using System.Windows; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// A window nobody can see asks the machine nothing - backlog 467, G-5 of the external performance +/// report of 2026-09-28. +/// +/// Minimised is the case that was missing. WPF leaves IsVisible true on a minimised window, +/// and the timer was stopped only when IsVisible changed - so a window minimised for an afternoon +/// read the manager once a second all afternoon while a comment beside the timer said it did not. +/// +/// Held by what the machine is asked, not by the timer. The fake machine counts every +/// reading, which is the effect this is about, and a property handing the timer out for a test is +/// one more thing the product carries for nobody (DeadCodeGuards). +/// +public sealed class MinimisedWindowGuards +{ + /// + /// Minimised asks nothing for longer than the timer's own second, and coming back asks at once + /// rather than a second later - the list on screen is what the machine looked like when the + /// window went away. + /// + [Fact] + public async Task A_minimised_window_stops_asking_and_asks_at_once_when_it_comes_back() + { + var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler")); + var model = new MainViewModel(machine, new SteppedClock()); + + await model.LoadAsync(); + + var window = WpfHost.Window(model); + + WpfHost.On(() => + { + window.WindowStyle = WindowStyle.None; + window.ShowInTaskbar = false; + window.Left = -4000; + window.Show(); + }); + + // The first look reads the machine and starts the timer, whose tick is a cheap reading. + WpfHost.Until(() => machine.StatusReads > 0, "the window started asking once a second"); + + WpfHost.On(() => window.WindowState = WindowState.Minimized); + + // A reading already out when the window went away is let finish, or it would be counted + // against the minimised window. + await Task.Delay(200); + WpfHost.Settled(); + + var minimised = Asked(machine); + + await Task.Delay(1500); + + Assert.Equal(minimised, Asked(machine)); + + WpfHost.On(() => window.WindowState = WindowState.Normal); + + // "At once" is sooner than the timer's first tick, which is a second after it starts. + var clock = Stopwatch.StartNew(); + + while (Asked(machine) == minimised && clock.ElapsedMilliseconds < 600) + { + await Task.Delay(10); + } + + Assert.True( + Asked(machine) > minimised, + "Coming back from minimised asked the machine nothing for 600 ms - the timer's first tick is a second away."); + + WpfHost.On(window.Close); + } + + /// + /// A window opened minimised - a shortcut set to "Run: Minimized" - reads the machine once for + /// its first look and then waits to be looked at. The first look starts the timer itself, so it + /// has to ask the same question the rest of this file asks. + /// + [Fact] + public async Task A_window_opened_minimised_reads_once_and_waits_to_be_looked_at() + { + var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler")); + var model = new MainViewModel(machine, new SteppedClock()); + + await model.LoadAsync(); + + var window = WpfHost.Window(model); + + WpfHost.On(() => + { + window.WindowStyle = WindowStyle.None; + window.ShowInTaskbar = false; + window.Left = -4000; + window.WindowState = WindowState.Minimized; + window.Show(); + }); + + // The model was read once before the window, and the first look reads it again. + WpfHost.Until(() => machine.FullReads >= 2, "the first look read the machine"); + + await Task.Delay(200); + WpfHost.Settled(); + + var looked = Asked(machine); + + await Task.Delay(1500); + + Assert.Equal(looked, Asked(machine)); + + WpfHost.On(() => window.WindowState = WindowState.Normal); + + var clock = Stopwatch.StartNew(); + + while (Asked(machine) == looked && clock.ElapsedMilliseconds < 600) + { + await Task.Delay(10); + } + + Assert.True(Asked(machine) > looked, "Coming back to a window opened minimised asked the machine nothing for 600 ms."); + + WpfHost.On(window.Close); + } + + private static int Asked(LiveMachine machine) => machine.StatusReads + machine.FullReads; +} diff --git a/tests/Bws.Gui.Tests/PlanSheetListsTests.cs b/tests/Bws.Gui.Tests/PlanSheetListsTests.cs new file mode 100644 index 0000000..3a8a342 --- /dev/null +++ b/tests/Bws.Gui.Tests/PlanSheetListsTests.cs @@ -0,0 +1,53 @@ +using Bws.Core.Planning; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// The lists on the plan sheet are built once for a plan, not once for every binding that reads +/// them - G-7 of the external performance report of 2026-09-28, which counted four renderings of +/// every command and three builds of the warnings on one showing. +/// +public sealed class PlanSheetListsTests +{ + [Fact] + public void The_commands_and_the_warnings_are_the_same_lists_however_often_they_are_read() + { + var panel = new Planned { Elevated = true }; + + Assert.True(panel.Show(Over(ActionKind.Stop, "Spooler", "W32Time"))); + + Assert.NotEmpty(panel.Commands); + Assert.NotEmpty(panel.Warnings); + Assert.Same(panel.Commands, panel.Commands); + Assert.Same(panel.Warnings, panel.Warnings); + Assert.Equal(EquivalentCommand.For(panel.Plan!), panel.Commands); + + // Kept beside the plan and never past it: a new plan is a new list. + var first = panel.Commands; + + Assert.True(panel.Show(Over(ActionKind.Start, "Spooler"))); + Assert.NotSame(first, panel.Commands); + Assert.Equal(EquivalentCommand.For(panel.Plan!), panel.Commands); + + Assert.True(panel.Hide()); + Assert.Empty(panel.Commands); + Assert.Empty(panel.Warnings); + } + + private static BulkPlan Over(ActionKind kind, params string[] names) => new() + { + Action = new BulkAction(kind, names), + Plans = + [ + .. names.Select(name => new OperationPlan + { + Action = new ServiceAction(kind, name), + Steps = [new PlanStep(name, name, kind == ActionKind.Start ? StepOperation.Start : StepOperation.Stop, StepReason.Requested)], + Warnings = [new PlanWarning(PlanWarningKind.ReturnsAfterReboot, name, [])], + Problems = [] + }) + ], + Problems = [] + }; +} diff --git a/tests/Bws.Gui.Tests/QueryExampleTests.cs b/tests/Bws.Gui.Tests/QueryExampleTests.cs index c3ac00a..bf3f155 100644 --- a/tests/Bws.Gui.Tests/QueryExampleTests.cs +++ b/tests/Bws.Gui.Tests/QueryExampleTests.cs @@ -84,7 +84,7 @@ public void No_example_asks_for_a_reading_the_window_does_not_take() [Fact] public void The_question_about_what_should_be_running_is_the_one_the_chip_asks() { - var chip = FilterChips.Grouped(() => string.Empty, _ => { }) + var chip = FilterChips.Grouped(() => QueryAsTyped.Of(string.Empty), _ => { }) .SelectMany(group => group.Chips) .Single(chip => chip.Label == Texts.Of("gui.filter.shouldRun")); diff --git a/tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs b/tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs new file mode 100644 index 0000000..0a0e761 --- /dev/null +++ b/tests/Bws.Gui.Tests/QueryParsedOnceGuards.cs @@ -0,0 +1,103 @@ +using System.Runtime.ExceptionServices; +using Bws.Core.Querying; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// One keystroke parses the query once - G-4 of the external performance report of 2026-09-28, +/// which counted eighteen parses of the same text: one to narrow the list, one for the sentence +/// about the scope and one for each of the sixteen filter chips. +/// +/// Counted through the one thing a parse leaves behind that can be seen from outside, and +/// without a counter written into the product for a test. A pattern with a backreference is valid +/// and refused by the linear engine, so building it throws and catches (QueryPatterns.TryPattern) - +/// S-10 of the same report - and every parse of the line throws the same number of times. The +/// exceptions are counted only in this test's own flow of work (an AsyncLocal), because the event +/// is heard by the whole process and other tests run beside this one. +/// +public sealed class QueryParsedOnceGuards +{ + private const string Refused = "/(s)\\1/"; + + [Fact] + public async Task One_keystroke_parses_the_query_once_and_a_tick_does_not_parse_it_again() + { + var once = await Thrown(() => + { + QueryAsTyped.Of(Refused); + + return Task.CompletedTask; + }); + + // A GUARD SATISFIED BY ABSENCE: if the engine ever supports backreferences, nothing throws, + // both counts are zero and equal, and this would pass while counting nothing. + Assert.True(once > 0, "The pattern is no longer refused by the linear engine, so nothing here is counted - pick a construct it refuses."); + + var machine = new LiveMachine(Rows.Entry("Spooler", "Print Spooler"), Rows.Entry("W32Time", "Windows Time")); + var model = new MainViewModel(machine, new SteppedClock()); + + await model.LoadAsync(); + + // The chips are read the way their bindings read them after the text changed. + var typed = await Thrown(() => + { + model.QueryText = Refused; + Lit(model); + + return Task.CompletedTask; + }); + + Assert.Equal(once, typed); + + // A tick that finds a service moved re-runs the query over the list and asks the chips + // again - over the same text, so over the same parse. + machine.Stop("Spooler"); + + var ticked = await Thrown(async () => + { + await model.RefreshAsync(); + Lit(model); + }); + + Assert.Equal(0, ticked); + } + + private static void Lit(MainViewModel model) + { + foreach (var chip in model.Filters) + { + _ = chip.IsOn; + } + } + + /// + /// How many times the linear engine refused a pattern while this work ran, in this flow only. + /// + private static async Task Thrown(Func work) + { + var mine = new AsyncLocal { Value = true }; + var count = 0; + + void Seen(object? sender, FirstChanceExceptionEventArgs thrown) + { + if (mine.Value && thrown.Exception is NotSupportedException) + { + Interlocked.Increment(ref count); + } + } + + AppDomain.CurrentDomain.FirstChanceException += Seen; + + try + { + await work(); + } + finally + { + AppDomain.CurrentDomain.FirstChanceException -= Seen; + } + + return count; + } +} diff --git a/tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs b/tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs new file mode 100644 index 0000000..bb7511d --- /dev/null +++ b/tests/Bws.Gui.Tests/SecondPassSettingOutTests.cs @@ -0,0 +1,70 @@ +using Bws.Core; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// The second pass setting out - G-11 of the external performance report of 2026-09-28. +/// +/// It says the list is still being filled and does nothing else. Until that day it said so +/// by recutting the scope and running the query again over entries the reading just before it had +/// narrowed and shown - a whole rethink of the window to change one sentence. +/// +public sealed class SecondPassSettingOutTests +{ + private const string File = @"C:\windows\system32\spoolsv.exe"; + + /// + /// While the pass reads, the sentence under the list says the signatures are being read - the + /// one thing setting out has to say - and the query runs twice over the whole reading: once for + /// the entries, once for what the pass filled in. Never a third time in between. + /// + [Fact] + public async Task Setting_out_says_the_signatures_are_being_read_and_runs_the_query_only_around_the_pass() + { + MainViewModel? model = null; + string? saidWhileReading = null; + + // Read from inside the pass, which is the moment "still reading" has to be true. + var inspector = new Recording(() => saidWhileReading ??= model!.Says.Notice); + + model = new MainViewModel( + new LiveMachine(Rows.Entry("Spooler") with { BinaryFile = Reading.Present(File) }), + new SteppedClock(), + inspector); + + await model.LoadAsync(); + + model.QueryText = "signed:no"; + + var rethought = 0; + + model.Filters[0].PropertyChanged += (_, changed) => + { + if (changed.PropertyName == nameof(FilterChip.IsOn)) + { + rethought++; + } + }; + + await model.RefreshAsync(); + + Assert.NotNull(saidWhileReading); + Assert.Contains(Texts.Of("gui.query.readingSignatures"), saidWhileReading, StringComparison.Ordinal); + Assert.Equal(2, rethought); + } + + private sealed class Recording(Action asked) : IBinaryInspector + { + public Reading ReadSignature(string file) + { + asked(); + + return Reading.Present(new BinarySignature(SignatureStatus.NotSigned, 0, "Someone")); + } + + public Reading ReadFileVersion(string file) => Reading.Present("1.0.0.0"); + + public Reading ReadHash(string file) => Reading.Present(new string('a', 64)); + } +} diff --git a/tests/Bws.Gui.Tests/SuggestingTests.cs b/tests/Bws.Gui.Tests/SuggestingTests.cs index d21ef7e..d5eb56a 100644 --- a/tests/Bws.Gui.Tests/SuggestingTests.cs +++ b/tests/Bws.Gui.Tests/SuggestingTests.cs @@ -376,7 +376,7 @@ public void A_value_carries_the_label_of_its_chip_and_nothing_where_there_is_no_ // opposite of what writing the value would do. No such chip exists in the row today, so // one is built here to hold the rule. var excluding = new Suggesting( - () => [new FilterChip("gui.filter.stopped", "type", "driver", negated: true, () => string.Empty, _ => { })], + () => [new FilterChip("gui.filter.stopped", "type", "driver", negated: true, () => QueryAsTyped.Of(string.Empty), _ => { })], () => QueryExamples.All); excluding.Keyboard(present: true); diff --git a/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs b/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs index 81a239c..71d1100 100644 --- a/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs +++ b/tests/Bws.Integration.Tests/PerformanceBudgetTests.cs @@ -125,9 +125,10 @@ public void Expensive_data_for_the_whole_machine_stays_inside_the_budget() /// /// This test exists to answer a question the window's design rests on rather than to watch a /// number. Building a plan asks the manager who depends on each entry, so a selection of - /// twenty costs a question per entry plus one per cascade member - and the window works that out - /// on the interface thread, with no background pass, no cancellation and no generation counter. - /// That choice was made on a measurement and this is the measurement kept where it can go red. + /// twenty costs a question per entry plus one per cascade member. The window worked that out on + /// the interface thread until 2026-09-03, when it moved off it with a generation counter + /// (PlanBuildingGuards) - and the budget here still stands, because a preview slower than section + /// 8.1 allows is late whichever thread is waiting for it. /// /// What was measured before the code, through the command line, five runs each with the first /// discarded: a plan with a thirteen member cascade took 316-360 ms end to end and the same @@ -192,6 +193,6 @@ public void A_plan_over_a_large_selection_is_worked_out_inside_the_window_s_budg $"the {budget.TotalSeconds:F0} s section 8.1 gives this window for an answer. CHECK WHAT " + "ELSE WAS RUNNING BEFORE LOOKING AT THE CODE - backlog 200 is about exactly that shape on " + "the test above. If the run was alone, the cost has started scaling with the selection, and " + - "the window works this out on the thread that draws it."); + "the window waits that long for the plan to show."); } }