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/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/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/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/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/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/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.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); + } +} 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."); } }