From 71a42287b2171890197774da878ed0b04578293d Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 27 Oct 2026 14:46:36 +0100 Subject: [PATCH 1/3] Keep the window, its queries and the change history honest A query with a pattern only the backtracking engine can run now has about 200 ms for the whole list. The entries after that are never checked and say so, where each entry used to have 50 ms of its own - 37.5 s over 804 entries, 0.53-0.60 s now. A row whose service moves to another process no longer shows the old process's memory: none when it stopped, unread when it runs elsewhere, and the panel and a memory column read the new figure. When every row's memory is held, the moved rows are read again inside the same refresh, before the list settles, rather than as work left running beside it. After a plan run from the window, the change history is read again once the System log has had time to record the change. Measured: event 7040 appears about one second after the change, so the reading waits two. The history no longer promises that the log "has lost nothing since", reads the log's mode from its configuration, says an archived log may keep older events in files it does not read, and adds "mode" at the end of "log" in bws history --json. "Not by SYSTEM" leaves out changes recorded without an account. The table sorts by the moment rather than the local text, and the exported file carries each time's offset. An SHA-1 catalogue that is found and cannot be verified is a refusal with Windows' number rather than "not signed", and the history is parsed event by event instead of being held as text first. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 +++ src/Bws.Cli/HistoryJson.cs | 9 +- src/Bws.Cli/HistoryText.cs | 18 +- src/Bws.Cli/Resources/cli.en.json | 2 +- src/Bws.Core/History/ChangeHistory.cs | 49 +++++- .../History/WindowsChangeHistoryReader.cs | 53 ++++-- src/Bws.Core/Querying/Query.cs | 53 +++++- src/Bws.Core/Querying/QueryBudget.cs | 78 ++++++++ src/Bws.Core/Querying/QueryValues.cs | 45 +++-- .../WindowsBinaryInspector.Catalogue.cs | 38 +++- src/Bws.Gui/HistoryTable.xaml | 9 +- src/Bws.Gui/HistoryTable.xaml.cs | 2 +- src/Bws.Gui/Resources/gui.en.json | 2 +- src/Bws.Gui/ViewModels/Catalogue.Histories.cs | 2 +- src/Bws.Gui/ViewModels/EntryRow.cs | 23 ++- src/Bws.Gui/ViewModels/HistoryExporting.cs | 18 +- src/Bws.Gui/ViewModels/HistoryFilter.cs | 7 +- .../ViewModels/HistoryReading.AfterPlan.cs | 86 +++++++++ src/Bws.Gui/ViewModels/HistoryReading.cs | 13 +- src/Bws.Gui/ViewModels/HistoryRow.cs | 17 +- src/Bws.Gui/ViewModels/HistoryWords.cs | 23 ++- .../ViewModels/MainViewModel.History.cs | 5 +- src/Bws.Gui/ViewModels/MainViewModel.cs | 13 +- src/Bws.Gui/ViewModels/Narrowing.cs | 9 +- src/Bws.Gui/ViewModels/Readings.OneEntry.cs | 67 ++++++- src/Bws.Gui/ViewModels/Readings.cs | 1 + .../SilentCatchGuards.cs | 5 +- tests/Bws.Cli.Tests/HistoryOutputTests.cs | 31 +++- .../MachineDocumentValueGuards.cs | 17 +- tests/Bws.Core.Tests/CatalogueRescueTests.cs | 56 ++++++ tests/Bws.Core.Tests/ChangeHistoryTests.cs | 24 ++- tests/Bws.Core.Tests/QueryBudgetTests.cs | 97 ++++++++++ tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs | 70 ++++++++ tests/Bws.Gui.Tests/HistoryClaimsTests.cs | 40 +++++ tests/Bws.Gui.Tests/HistoryExportingTests.cs | 11 +- tests/Bws.Gui.Tests/HistoryLog.cs | 2 +- tests/Bws.Gui.Tests/HistoryWindowGuards.cs | 22 ++- tests/Bws.Gui.Tests/PanelReadingTests.cs | 58 ++++++ tests/Bws.Gui.Tests/RowMemoryTests.cs | 61 +++++++ .../HistoryContractTests.cs | 10 ++ tests/mutation/entries.ps1 | 166 ++++++++++++++++-- 41 files changed, 1207 insertions(+), 128 deletions(-) create mode 100644 src/Bws.Core/Querying/QueryBudget.cs create mode 100644 src/Bws.Gui/ViewModels/HistoryReading.AfterPlan.cs create mode 100644 tests/Bws.Core.Tests/CatalogueRescueTests.cs create mode 100644 tests/Bws.Core.Tests/QueryBudgetTests.cs create mode 100644 tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs create mode 100644 tests/Bws.Gui.Tests/HistoryClaimsTests.cs create mode 100644 tests/Bws.Gui.Tests/RowMemoryTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9ddd10..a44e71f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,29 @@ is not part of this repository. new snapshot or put the comparison away no longer takes the middle of the window back - the line at the foot says it finished and was not shown. Saving a snapshot no longer closes a form for a new snapshot opened while the first one was being read. +- A query with a pattern that only the slower regular expression engine can run - a lookahead or a + backreference - now spends about a fifth of a second in it over the whole list, and the entries + left after that are reported as never checked. Each entry used to have fifty milliseconds of its + own, so such a pattern could hold `bws list` and the window still for over half a minute. +- In the window, a service that stops or starts again in another process no longer shows the memory + of the process it had before. A stopped service shows no memory in use, and the panel reads the new + process's figure, as does the memory column when it is shown. +- After a plan run from the window, the change history - the view and the section in the panel - is + read again once the System log has had time to record the change, about two seconds later. It used + to stay as it was until F5, without the change the plan had just made. +- The change history no longer says the System log "has lost nothing since" its oldest record: nothing + about a log shows it was written without a gap. A log set to be archived when full now says that + older events may be in archive files, which are not read, instead of being said to overwrite its + oldest events. `bws history --json` names the log's mode in a new `mode` field at the end of `log`: + `Circular`, `AutoBackup` or `Retain`, as `Get-WinEvent -ListLog` names it. +- *Not by SYSTEM* in the window's change history no longer includes changes the log recorded without + an account, which may have been made by SYSTEM. They stay under *All changes*. +- The window's change history sorts by when a change happened rather than by the time as written on + this machine, so the hour the clocks go back is in order. The exported file writes each time with its + offset from UTC, so two changes in that hour no longer look like one moment. +- A file whose signature is in an SHA-1 catalogue that Windows finds but cannot verify is now reported + as a signature that could not be read, with Windows' error number. It used to be reported as not + signed. A file in no catalogue, or a machine that cannot check SHA-1 catalogues, is still not signed. - `bws kill`, and Force stop and Force restart in the window, now ask the other services living in the same process to stop in an order Windows accepts: a service another of them needs is asked after it. They used to be asked in the order of the list, so a service another one still needed was diff --git a/src/Bws.Cli/HistoryJson.cs b/src/Bws.Cli/HistoryJson.cs index 4a72bd93..47a3bc34 100644 --- a/src/Bws.Cli/HistoryJson.cs +++ b/src/Bws.Cli/HistoryJson.cs @@ -33,7 +33,7 @@ internal static string Render(LogCoverage log, IReadOnlyList chan ArgumentNullException.ThrowIfNull(changes); var document = new HistoryDocument( - new LogDocument(log.Name, log.ReachesBack, log.Overwritten, log.Full, Cleared(log.Cleared)), + new LogDocument(log.Name, log.ReachesBack, log.Overwritten, log.Full, Cleared(log.Cleared), log.Mode.ToString()), [.. changes.Select(Change)]); return AsciiJson.Serialize(document, Options); @@ -61,7 +61,12 @@ internal static string Render(LogCoverage log, IReadOnlyList chan // The order of the members below IS the order of the keys in the document. private sealed record HistoryDocument(LogDocument Log, IReadOnlyList Changes); - private sealed record LogDocument(string Name, DateTimeOffset? ReachesBack, bool Overwritten, bool Full, ClearedDocument? Cleared); + /// + /// What Windows does at the size limit, spelled as spells it - added 2026-10-27 + /// (logic report H02) and LAST, so the document only grew at its end. + /// + private sealed record LogDocument( + string Name, DateTimeOffset? ReachesBack, bool Overwritten, bool Full, ClearedDocument? Cleared, string Mode); private sealed record ClearedDocument(DateTimeOffset? At, ActorDocument? By); diff --git a/src/Bws.Cli/HistoryText.cs b/src/Bws.Cli/HistoryText.cs index 0e5b00dd..6c80e9c0 100644 --- a/src/Bws.Cli/HistoryText.cs +++ b/src/Bws.Cli/HistoryText.cs @@ -34,6 +34,13 @@ internal static IReadOnlyList Admissions(ChangeHistory history, IReadOnl lines.Add(Texts.Of("cli.history.log.full")); } + // An archived log keeps its past in other files whatever its size is now, so this is said every + // time rather than at the limit - H02, 2026-10-27. + if (history.Log.Mode == LogMode.AutoBackup) + { + lines.Add(Texts.Of("cli.history.log.autoBackup")); + } + if (serviceName is null && history.HasInstallations) { lines.Add(Texts.Of("cli.history.installedUnderDisplayName")); @@ -107,8 +114,11 @@ internal static string Render(IReadOnlyList changes) } /// - /// How far back the log reaches, as the first sentence. Three shapes and one for an empty log, so - /// that "has lost nothing since" is said only when nothing says otherwise. + /// How far back the log reaches, as the first sentence - and only that. + /// + /// "And has lost nothing since" went on 2026-10-27 (logic report H02): it was said whenever the + /// log was neither at its limit nor full, and neither says the log was written to without a gap - a + /// stopped log service, a log that stood full until somebody made it larger, an archived one. /// private static string Reach(LogCoverage log) { @@ -124,9 +134,7 @@ private static string Reach(LogCoverage log) : Texts.Of("cli.history.log.cleared", When(oldest), Who(cleared.By)); } - return log.Overwritten || log.Full - ? Texts.Of("cli.history.log.reaches", When(oldest)) - : Texts.Of("cli.history.log.since", When(oldest)); + return Texts.Of("cli.history.log.reaches", When(oldest)); } /// diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 8670cb15..3d7a9d7d 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -242,13 +242,13 @@ "cli.show.requiredPrivileges": "Required privileges", "cli.show.securityDescriptor": "Security descriptor", "cli.show.noSuchEntry": "There is no entry called {0}.", - "cli.history.log.since": "The System log reaches back to {0} and has lost nothing since.", "cli.history.log.reaches": "The System log reaches back to {0}.", "cli.history.log.cleared": "The System log reaches back to {0}, when {1} cleared it.", "cli.history.log.clearedBySomebody": "The System log reaches back to {0}, when it was cleared.", "cli.history.log.empty": "The System log is empty.", "cli.history.log.overwritten": "It is at its size limit, so Windows overwrites its oldest events and anything older is gone.", "cli.history.log.full": "It is full and set to keep what it has, so Windows writes no new events into it and the latest changes are missing.", + "cli.history.log.autoBackup": "It is set to be archived when full, so older events may be in archive files beside it, which bws does not read.", "cli.history.installedUnderDisplayName": "An installation shows the display name in quotes, because Windows records it without the service name.", "cli.history.installationsNotByName": "Installations are not listed for one name, because Windows records them without the service name. bws history lists them all.", "cli.history.unnamed.one": "{0} start type change does not name its entry, so it is not listed here.", diff --git a/src/Bws.Core/History/ChangeHistory.cs b/src/Bws.Core/History/ChangeHistory.cs index b45c3a4c..3a6af473 100644 --- a/src/Bws.Core/History/ChangeHistory.cs +++ b/src/Bws.Core/History/ChangeHistory.cs @@ -24,23 +24,54 @@ public sealed record LogCoverage /// The channel, which is System for everything this reads today. public required string Name { get; init; } - /// When the oldest record in the log was written. Null for an empty log. + /// + /// When the oldest record in the log was written. Null for an empty log. + /// + /// Not a promise that nothing is missing since - logic report H02, 2026-10-27. Nothing about + /// a log says it was written to without a gap: the log service can have been stopped, a log set to + /// keep what it has can have stood full for a week before somebody made it larger, and an archived + /// log keeps its past in other files. + /// public required DateTimeOffset? ReachesBack { get; init; } /// /// The log has no room left and Windows makes room by throwing its oldest events away, so the - /// past it covers moves forward as new events arrive. + /// past it covers moves forward as new events arrive. Only ever true for . /// public required bool Overwritten { get; init; } /// /// Windows says the log is full. With a log set never to overwrite, that means NEW events are not - /// being written at all - the recent end of the history is the one missing. + /// being written at all - the recent end of the history is the one missing. Windows documents it as + /// always false for the two other modes, so it only speaks for . /// public required bool Full { get; init; } /// Set when the oldest record says the log was cleared, and by whom. public Clearing? Cleared { get; init; } + + /// What Windows does when the log reaches its size limit - read, not inferred (H02). + public required LogMode Mode { get; init; } +} + +/// +/// What Windows does when a log reaches its size limit - since 2026-10-27, logic report H02. Named as +/// Get-WinEvent -ListLog names it in its LogMode column, the words an administrator already reads. +/// +/// Read from the channel's configuration, not inferred from its state. Until that day the size and +/// EvtLogFull stood in for it, and Windows documents that flag as always false for two of these +/// three - so an archived log at its limit was said to be overwriting its oldest events. +/// +public enum LogMode +{ + /// The oldest events are overwritten - retention off, the default for the System log. + Circular, + + /// The full file is put aside as Archive-System-*.evtx and a new one begun - nothing is overwritten. + AutoBackup, + + /// Nothing is overwritten and nothing is put aside, so a full log takes no new events. + Retain } /// @@ -49,7 +80,8 @@ public sealed record LogCoverage /// The size of the log file now, in bytes. /// The size the channel is configured never to grow beyond, in bytes. /// Whether Windows reports the log as full. -public sealed record LogFacts(ulong FileSize, ulong MaximumSize, bool Full) +/// What the channel is configured to do at its size limit. +public sealed record LogFacts(ulong FileSize, ulong MaximumSize, bool Full, LogMode Mode) { /// /// How much a log file grows at a time. The file is a header followed by chunks of this size, and @@ -108,6 +140,10 @@ public IReadOnlyList Of(string serviceName) => /// Overwritten and full exclude each other: a full log is one that will not overwrite, and a /// log that overwrites is never reported full - on the owner's machine the Security log sat at its /// limit, overwriting, and reported not full. + /// + /// Overwritten only for a circular log, since 2026-10-27 (H02). It was "not full and at the + /// limit", which also fits an archived log at its limit - Windows puts that file aside rather than + /// overwriting it, and reports it never full. /// public static LogCoverage Cover(string name, LogFacts facts, DateTimeOffset? oldest, Clearing? cleared) { @@ -117,9 +153,10 @@ public static LogCoverage Cover(string name, LogFacts facts, DateTimeOffset? old { Name = name, ReachesBack = oldest, - Overwritten = !facts.Full && facts.AtSizeLimit, + Overwritten = facts.Mode == LogMode.Circular && facts.AtSizeLimit, Full = facts.Full, - Cleared = cleared + Cleared = cleared, + Mode = facts.Mode }; } } diff --git a/src/Bws.Core/History/WindowsChangeHistoryReader.cs b/src/Bws.Core/History/WindowsChangeHistoryReader.cs index e44261f3..cdf37366 100644 --- a/src/Bws.Core/History/WindowsChangeHistoryReader.cs +++ b/src/Bws.Core/History/WindowsChangeHistoryReader.cs @@ -49,13 +49,15 @@ public ChangeHistory Read() // The oldest record says how far back the log reaches, and - when it is the event Windows // writes after emptying a log - who emptied it. - var oldest = Rendered("*", newestFirst: false, limit: 1); + var oldest = Rendered("*", newestFirst: false, limit: 1, xml => xml); - var changes = Rendered(Changes, newestFirst: true, limit: int.MaxValue) - .Select(xml => ChangeEvents.Change(xml) - ?? throw new InvalidDataException( - "The System log answered a query for events 7040 and 7045 with an event that is neither.")) - .ToList(); + // EACH EVENT PARSED AS IT IS RENDERED, since 2026-10-27 - logic report C02, owner's decision "parse + // on the fly". Until then every event was kept as its XML in one list and parsed afterwards, so a + // large log held all of its text and all of its changes at once. The range and the absence of a + // limit are unchanged, by the same decision: only the text goes once it has been read. + var changes = Rendered(Changes, newestFirst: true, limit: int.MaxValue, xml => ChangeEvents.Change(xml) + ?? throw new InvalidDataException( + "The System log answered a query for events 7040 and 7045 with an event that is neither.")); var cleared = oldest.Count == 0 ? null : ChangeEvents.ClearingOf(oldest[0], Channel); var reach = oldest.Count == 0 ? null : ChangeEvents.TimeOf(oldest[0]); @@ -98,7 +100,35 @@ private static LogFacts Facts() Expect(maximum, EVT_VARIANT_TYPE.EvtVarTypeUInt64, "size limit"); - return new LogFacts(size, maximum.UInt64Val, full); + return new LogFacts(size, maximum.UInt64Val, full, Mode(channel)); + } + + /// + /// What the channel does at its size limit - H02, 2026-10-27. Microsoft's channel schema: retention true + /// is a sequential log, false a circular one, and autoBackup may only be true with retention, when the + /// full file is put aside as an archive. Autobackup without retention is not a shape Windows accepts, so + /// such an answer is read as what retention alone says - circular. + /// + private static LogMode Mode(SafeHandle channel) + { + var retention = Configured(channel, EVT_CHANNEL_CONFIG_PROPERTY_ID.EvtChannelLoggingConfigRetention, "retention"); + var autoBackup = Configured(channel, EVT_CHANNEL_CONFIG_PROPERTY_ID.EvtChannelLoggingConfigAutoBackup, "automatic backup"); + + return !retention ? LogMode.Circular + : autoBackup ? LogMode.AutoBackup + : LogMode.Retain; + } + + private static bool Configured(SafeHandle channel, EVT_CHANNEL_CONFIG_PROPERTY_ID property, string what) + { + if (!PInvoke.EvtGetChannelConfigProperty(channel, property, 0, out EVT_VARIANT value, out _)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), $"Could not read the {what} setting of the System event log."); + } + + Expect(value, EVT_VARIANT_TYPE.EvtVarTypeBoolean, what); + + return value.BooleanVal; } private static EVT_VARIANT Property(SafeHandle log, EVT_LOG_PROPERTY_ID property, EVT_VARIANT_TYPE type) @@ -127,9 +157,10 @@ private static void Expect(EVT_VARIANT value, EVT_VARIANT_TYPE type, string what } /// - /// The events a query matches, rendered as XML, newest or oldest first. + /// The events a query matches, newest or oldest first, each rendered as XML and handed to + /// at once - the text of one event is all that is held at a time. /// - private static List Rendered(string query, bool newestFirst, int limit) + private static List Rendered(string query, bool newestFirst, int limit, Func parse) { var direction = newestFirst ? EVT_QUERY_FLAGS.EvtQueryReverseDirection : EVT_QUERY_FLAGS.EvtQueryForwardDirection; @@ -140,7 +171,7 @@ private static List Rendered(string query, bool newestFirst, int limit) throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not query the System event log."); } - var found = new List(); + var found = new List(); var handles = new nint[Batch]; var renderer = new Renderer(); @@ -152,7 +183,7 @@ private static List Rendered(string query, bool newestFirst, int limit) try { - found.AddRange(events.Take(limit - found.Count).Select(renderer.Xml)); + found.AddRange(events.Take(limit - found.Count).Select(one => parse(renderer.Xml(one)))); } finally { diff --git a/src/Bws.Core/Querying/Query.cs b/src/Bws.Core/Querying/Query.cs index 3064e13b..64164c54 100644 --- a/src/Bws.Core/Querying/Query.cs +++ b/src/Bws.Core/Querying/Query.cs @@ -135,6 +135,9 @@ public bool Carries(string field, string value, bool negated) return false; } + /// + /// The whole listing judged in one walk, with one budget for costly patterns (). + /// public QueryResult Filter(IReadOnlyList entries) { if (IsEmpty) @@ -146,10 +149,11 @@ public QueryResult Filter(IReadOnlyList entries) var unreadable = 0; var tooCostly = 0; var unvouched = 0; + var pass = Pass(); foreach (var entry in entries) { - var match = Match(entry); + var match = pass.Match(entry); if (match.Matched) { @@ -175,6 +179,19 @@ public QueryResult Filter(IReadOnlyList entries) return new QueryResult(selected, unreadable, tooCostly, unvouched); } + /// + /// One walk over a listing, which every entry of it is judged in - so a costly pattern has one budget + /// for the whole listing rather than one per entry (logic report R10, 2026-10-27). Begin one per walk: + /// the window narrows its rows through one, and does. + /// + public QueryPass Pass() => new(this, new QueryBudget()); + + /// + /// Judges one entry against every member, as a walk of its own - a costly pattern has the whole + /// budget for this one entry. A listing goes through . + /// + public QueryMatch Match(ScmEntry entry) => Match(entry, new QueryBudget()); + /// /// Judges one entry against every member. /// @@ -184,7 +201,7 @@ public QueryResult Filter(IReadOnlyList entries) /// admission depend on typing order, which is exactly the kind of difference nobody /// would ever find. /// - public QueryMatch Match(ScmEntry entry) + internal QueryMatch Match(ScmEntry entry, QueryBudget budget) { var matched = true; var unreadable = false; @@ -194,8 +211,8 @@ public QueryMatch Match(ScmEntry entry) foreach (var term in _terms) { var verdict = term.Field is null - ? AcrossFreeSearch(term.Values, entry) - : AnyValue(term.Field, term.Values, entry); + ? AcrossFreeSearch(term.Values, entry, budget) + : AnyValue(term.Field, term.Values, entry, budget); unreadable |= verdict.Unreadable; tooCostly |= verdict.TooCostly; @@ -214,27 +231,47 @@ public QueryMatch Match(ScmEntry entry) return new QueryMatch(matched, unreadable, tooCostly, unvouched); } - private static Verdict AnyValue(QueryField field, IReadOnlyList values, ScmEntry entry) + private static Verdict AnyValue(QueryField field, IReadOnlyList values, ScmEntry entry, QueryBudget budget) { var verdict = Verdict.NoMatch; foreach (var value in values) { - verdict = verdict.Or(value.Test(field, entry)); + verdict = verdict.Or(value.Test(field, entry, budget)); } return verdict; } - private static Verdict AcrossFreeSearch(IReadOnlyList values, ScmEntry entry) + private static Verdict AcrossFreeSearch(IReadOnlyList values, ScmEntry entry, QueryBudget budget) { var verdict = Verdict.NoMatch; foreach (var field in QueryFields.FreeSearch) { - verdict = verdict.Or(AnyValue(field, values, entry)); + verdict = verdict.Or(AnyValue(field, values, entry, budget)); } return verdict; } } + +/// +/// One walk of a query over a listing - every entry judged with the same budget for costly patterns, so +/// the walk as a whole is bounded rather than each entry (logic report R10). Not shared between walks and +/// not between threads: it is one walk's state. +/// +public sealed class QueryPass +{ + private readonly Query _query; + private readonly QueryBudget _budget; + + internal QueryPass(Query query, QueryBudget budget) + { + _query = query; + _budget = budget; + } + + /// The next entry of the walk. + public QueryMatch Match(ScmEntry entry) => _query.Match(entry, _budget); +} diff --git a/src/Bws.Core/Querying/QueryBudget.cs b/src/Bws.Core/Querying/QueryBudget.cs new file mode 100644 index 00000000..bdf4a485 --- /dev/null +++ b/src/Bws.Core/Querying/QueryBudget.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace Bws.Core.Querying; + +/// +/// How long ONE walk over a listing may spend inside patterns the linear engine would not take - since +/// 2026-10-27, logic report R10, owner's decision "a budget for the whole query". +/// +/// Why one value's limit was not enough, measured. Each match on the backtracking engine has fifty +/// milliseconds (), and nothing bounded their sum: on 2026-10-26, elevated, over +/// 804 entries, bws list --query 'display:/(?=.)((\w|\s)+)+#/' took 37.5 s and reported 582 entries +/// it ran out of time on - one run, a showing that it happens rather than a comparison. In the window the +/// same walk runs on its own thread at every keystroke and every tick that moved something. +/// +/// Only the backtracking engine is charged. The linear one cannot run away - it was chosen for that +/// - so a walk spending its time there is a walk over many entries, not a pattern costing too much, and a +/// budget on it would refuse ordinary questions on a large machine. Once is spent, every +/// further backtracking match in the same walk is not tried at all and answers that it ran out of time, the +/// verdict the language already turns into "never checked" in both interfaces. So a walk costs about this +/// much plus the one match that crossed the line, which has its own fifty milliseconds. +/// +/// Measured on the wall clock, like the limit per value, and with the same price: a machine busy with +/// something else spends this budget too. The alternative is processor time, which .NET does not offer per +/// match, and the budget exists to keep a window answering, which is wall clock. +/// +internal sealed class QueryBudget +{ + /// All the backtracking one walk may do - the figure the owner chose, "about 200 ms" for the list. + internal static readonly TimeSpan Whole = TimeSpan.FromMilliseconds(200); + + private readonly Func _ruler; + private readonly TimeSpan _whole; + private TimeSpan _spent; + + internal QueryBudget() + : this(() => Stopwatch.GetElapsedTime(0), Whole) + { + } + + /// A count that only goes forward - 's kind - so a test can hand one in. + /// How much backtracking the walk may do. + internal QueryBudget(Func ruler, TimeSpan whole) + { + _ruler = ruler; + _whole = whole; + } + + /// + /// The pattern asked about the value, paid for out of this walk - or not asked at all, once the walk has + /// nothing left, and then it ran out of time without anybody having checked. + /// + internal Verdict Match(Regex pattern, string value) + { + if (_spent >= _whole) + { + return Verdict.RanOutOfTime; + } + + var began = _ruler(); + + try + { + return Verdict.Of(pattern.IsMatch(value)); + } + catch (RegexMatchTimeoutException) + { + // Only reachable for expressions the linear engine would not take, where the time limit is the + // net. Letting this escape would end the run with a stack trace, and a quiet no would be worse + // still: an expression that ran out of time has not answered, and saying so is the whole point. + return Verdict.RanOutOfTime; + } + finally + { + _spent += _ruler() - began; + } + } +} diff --git a/src/Bws.Core/Querying/QueryValues.cs b/src/Bws.Core/Querying/QueryValues.cs index 397a5d0e..9f2cbdc1 100644 --- a/src/Bws.Core/Querying/QueryValues.cs +++ b/src/Bws.Core/Querying/QueryValues.cs @@ -57,7 +57,8 @@ internal readonly record struct Verdict(bool Matched, bool Unreadable, bool TooC /// One value inside a member, already compiled and ready to be asked about entries. internal interface IQueryValue { - Verdict Test(QueryField field, ScmEntry entry); + /// The walk this question is part of, which a costly pattern pays out of (R10). + Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget); /// /// Whether this value only bounds a quantity from one side - >, >=, @@ -106,7 +107,7 @@ internal static bool Unanswerable(QueryField field, ScmEntry entry) => /// internal sealed class OutcomeValue(ReadOutcome wanted) : IQueryValue { - public Verdict Test(QueryField field, ScmEntry entry) => + public Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget) => QueryValue.Unanswerable(field, entry) ? Verdict.CouldNotRead : Verdict.Of(field.OutcomeOf(entry) == wanted); @@ -127,7 +128,7 @@ public Verdict Test(QueryField field, ScmEntry entry) => /// internal sealed class UnansweredValue : IQueryValue { - public Verdict Test(QueryField field, ScmEntry entry) => + public Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget) => Verdict.Of(QueryValue.Unanswerable(field, entry)); } @@ -148,7 +149,13 @@ internal enum TextOperator /// internal sealed class TextValue(TextOperator operation, string text, Regex? pattern) : IQueryValue { - public Verdict Test(QueryField field, ScmEntry entry) + /// + /// Whether the pattern runs on the engine that can run away - the only one a walk's budget is charged + /// for (R10). A wildcard and most patterns get the linear engine (). + /// + private readonly bool _costly = pattern is not null && !pattern.Options.HasFlag(RegexOptions.NonBacktracking); + + public Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget) { if (QueryValue.Unanswerable(field, entry)) { @@ -164,10 +171,10 @@ public Verdict Test(QueryField field, ScmEntry entry) if (field.TextsOf is not null) { - return Across(field.TextsOf(entry)); + return Across(field.TextsOf(entry), budget); } - return Against(field.TextOf!(entry)); + return Against(field.TextOf!(entry), budget); } /// @@ -175,7 +182,7 @@ public Verdict Test(QueryField field, ScmEntry entry) /// running out of time is carried out whole rather than being turned into a no - the /// same rule the alternatives inside a member follow. /// - private Verdict Across(IReadOnlyList? values) + private Verdict Across(IReadOnlyList? values, QueryBudget budget) { if (values is null) { @@ -186,13 +193,13 @@ private Verdict Across(IReadOnlyList? values) foreach (var value in values) { - verdict = verdict.Or(Against(value)); + verdict = verdict.Or(Against(value, budget)); } return verdict; } - private Verdict Against(string? value) + private Verdict Against(string? value, QueryBudget budget) { if (value is null) { @@ -212,18 +219,8 @@ private Verdict Against(string? value) return Verdict.Of(value.Equals(text, StringComparison.OrdinalIgnoreCase)); } - try - { - return Verdict.Of(pattern!.IsMatch(value)); - } - catch (RegexMatchTimeoutException) - { - // Only reachable for expressions the linear engine would not take, where the - // time limit is the net. Letting this escape would end the run with a stack - // trace, and a quiet no would be worse still: an expression that ran out of - // time has not answered, and saying so is the whole point of having a limit. - return Verdict.RanOutOfTime; - } + // The linear engine needs no budget and cannot time out - no limit was set on it, at QueryPatterns. + return _costly ? budget.Match(pattern!, value) : Verdict.Of(pattern!.IsMatch(value)); } } @@ -233,7 +230,7 @@ private Verdict Against(string? value) /// internal sealed class SymbolValue(IReadOnlyList wanted) : IQueryValue { - public Verdict Test(QueryField field, ScmEntry entry) + public Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget) { var present = field.SymbolsOf!(entry); @@ -287,7 +284,7 @@ internal sealed class NumberValue(NumberOperator operation, int low, int high) : { public bool IsOpenComparison => NumberOperators.IsOpen(operation); - public Verdict Test(QueryField field, ScmEntry entry) + public Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget) { if (QueryValue.Unanswerable(field, entry)) { @@ -327,7 +324,7 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : { public bool IsOpenComparison => NumberOperators.IsOpen(operation); - public Verdict Test(QueryField field, ScmEntry entry) + public Verdict Test(QueryField field, ScmEntry entry, QueryBudget budget) { if (QueryValue.Unanswerable(field, entry)) { diff --git a/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs b/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs index 4ad475d1..5449c60d 100644 --- a/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs +++ b/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs @@ -31,7 +31,7 @@ public sealed partial class WindowsBinaryInspector /// private Reading ThroughCatalogue(string file, SafeFileHandle handle, HANDLE raw) { - if (ThroughCatalogue(file, handle, raw, "SHA256") is { } bySha256) + if (ThroughCatalogue(file, handle, raw, "SHA256", out _) is { } bySha256) { return bySha256; } @@ -45,14 +45,28 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha // catalogue under either. It costs one more hash per file no SHA-256 catalogue lists - one // file there. // - // Only a verdict counts. A machine that will not hash with SHA-1, or a catalogue that cannot - // be read once found, gets the answer this gave before that day rather than a refusal for - // every unsigned file: the question that decides was answered above, this one only rescues. - return ThroughCatalogue(file, handle, raw, "SHA1") is { Outcome: ReadOutcome.Present } bySha1 - ? bySha1 - : Reading.Present(new BinarySignature(SignatureStatus.NotSigned, NoSignature, Publisher: null)); + // WHETHER A CATALOGUE WAS FOUND DECIDES, since 2026-10-27 - logic report C03, owner's decision + // "two cases apart". A machine that will not hash with SHA-1, or no catalogue listing the file + // under it, still gets the answer this gave before 2026-10-05 rather than a refusal for every + // unsigned file: that question was answered above, and this one only rescues. But a catalogue + // that lists the file and then cannot be read or verified is not "nobody signed this" - the + // file is somebody's member, and a refusal with Windows' number is what is known. Until that day + // it came back as NotSigned too, a confident answer about a file that may well be signed. + var bySha1 = ThroughCatalogue(file, handle, raw, "SHA1", out var listed); + + return AfterSha1(bySha1, listed); } + /// + /// The rule above on its own, so a test can hold it without a catalogue that fails on demand - no + /// machine here has one (C03). What the verification said when a catalogue was found, and "not signed" + /// when none was, or the question could not be asked at all. + /// + internal static Reading AfterSha1(Reading? answer, bool listed) => + listed && answer is { } found + ? found + : Reading.Present(new BinarySignature(SignatureStatus.NotSigned, NoSignature, Publisher: null)); + /// /// One catalogue question asked with one hash algorithm. Null when no catalogue lists the file /// under it, which is not yet an answer - see the method above. @@ -60,9 +74,15 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha /// The member hash comes from the same handle as everything else, and measured 2026-10-06 it /// does not depend on where the handle's file pointer stands (one-handle.ps1, P6). /// + /// + /// Whether a catalogue listing the file was found - true for every answer from the verification + /// against it, refusals included, and false for a refusal that came before anybody looked (C03). + /// private unsafe Reading? ThroughCatalogue( - string file, SafeFileHandle handle, HANDLE raw, string algorithm) + string file, SafeFileHandle handle, HANDLE raw, string algorithm, out bool listed) { + listed = false; + // One context per file, and a context held per worker instead was measured on 2026-09-29 // (tools/signature-probe/auto-cache.ps1, variant "held"). Acquiring and releasing is almost // free - what a fresh context costs is its FIRST catalogue lookup, about 1 ms per file on @@ -121,6 +141,8 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha return null; } + listed = true; + try { return VerifyAgainst(catalogue, admin, file, raw, hash); diff --git a/src/Bws.Gui/HistoryTable.xaml b/src/Bws.Gui/HistoryTable.xaml index d5dcc080..27b3a17b 100644 --- a/src/Bws.Gui/HistoryTable.xaml +++ b/src/Bws.Gui/HistoryTable.xaml @@ -15,9 +15,10 @@ log recorded, not a service somebody can act on - an installation names no entry at all. NEWEST FIRST UNTIL SOMEBODY SORTS, AND THE HEADING SAYS SO (AS-d, 2026-10-20): FirstSort lays the - sort on "When" and its mark with it. The time column sorts as text, which is right because every - time is in one shape that sorts the way it reads (HistoryWords.When). The count column sorts by - the number behind it - the text is empty for one, and text puts "9" after "56". + sort on "At" and its mark with it. BOTH TIME COLUMNS SORT BY THE MOMENT BEHIND THEM SINCE 2026-10-27 + (logic report H04), not by the text: the text is local time without its offset, and in the hour the + clocks go back it sorts a later change before an earlier one. The count column sorts by the number + behind it - the text is empty for one, and text puts "9" after "56". GONE WHEN NO LINE IS LEFT - an empty log, or a search that finds nothing. The sentences above the table say the first, the line beside the search the second (HistoryFilter.Showing). @@ -40,6 +41,7 @@ diff --git a/src/Bws.Gui/HistoryTable.xaml.cs b/src/Bws.Gui/HistoryTable.xaml.cs index 1be26844..b834a5c0 100644 --- a/src/Bws.Gui/HistoryTable.xaml.cs +++ b/src/Bws.Gui/HistoryTable.xaml.cs @@ -13,7 +13,7 @@ public HistoryTable() InitializeComponent(); // Newest first, with the mark on When saying so (AS-d). - FirstSort.Keep(Lines, nameof(HistoryRow.When), ListSortDirection.Descending); + FirstSort.Keep(Lines, nameof(HistoryRow.At), ListSortDirection.Descending); } /// diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index 790a0e4d..7b16001b 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -606,11 +606,11 @@ "gui.history.installedWith": "installed ({0}) {1}", "gui.history.log.empty": "The System log is empty.", "gui.history.log.reaches": "The System log reaches back to {0}.", - "gui.history.log.since": "The System log reaches back to {0} and has lost nothing since.", "gui.history.log.cleared": "The System log reaches back to {0}, when {1} cleared it.", "gui.history.log.clearedBySomebody": "The System log reaches back to {0}, when it was cleared.", "gui.history.log.overwritten": "It is at its size limit, so Windows overwrites its oldest events and anything older is gone.", "gui.history.log.full": "It is full and set to keep what it has, so Windows writes no new events into it and the latest changes are missing.", + "gui.history.log.autoBackup": "It is set to be archived when full, so older events may be in archive files beside it, which are not read here.", "gui.history.installedUnderDisplayName": "An installation shows the display name in quotes, because Windows records it without the service name.", "gui.history.installationsNotByName": "Installations are not listed for one entry, because Windows records them without the service name. Change history under Audit... lists them all.", "gui.history.unnamed.one": "{0} startup type change does not name its entry, so it is not listed here.", diff --git a/src/Bws.Gui/ViewModels/Catalogue.Histories.cs b/src/Bws.Gui/ViewModels/Catalogue.Histories.cs index 54eecc1d..eb7bce2a 100644 --- a/src/Bws.Gui/ViewModels/Catalogue.Histories.cs +++ b/src/Bws.Gui/ViewModels/Catalogue.Histories.cs @@ -86,7 +86,7 @@ internal sealed class Recorded(ChangeHistory history) : IChangeHistoryReader private static LogCoverage Log(bool cleared, bool overwritten) => ChangeHistory.Cover( "System", - new LogFacts(overwritten ? 21_000_000UL : 2_166_784UL, 20_971_520UL, Full: false), + new LogFacts(overwritten ? 21_000_000UL : 2_166_784UL, 20_971_520UL, Full: false, LogMode.Circular), At(11, 16, 58), cleared ? new Clearing(At(11, 16, 58), new Actor(PersonSid, @"CATALOGUE\admin")) : null); diff --git a/src/Bws.Gui/ViewModels/EntryRow.cs b/src/Bws.Gui/ViewModels/EntryRow.cs index a0660dd7..9a62785b 100644 --- a/src/Bws.Gui/ViewModels/EntryRow.cs +++ b/src/Bws.Gui/ViewModels/EntryRow.cs @@ -265,7 +265,12 @@ internal bool Absorb(ScmStatus status, DateTimeOffset now) return false; } - _entry = _entry with { Status = status.Status, ProcessId = status.ProcessId }; + _entry = _entry with + { + Status = status.Status, + ProcessId = status.ProcessId, + Memory = SameProcess(_entry.ProcessId, status.ProcessId) ? _entry.Memory : MemoryOfAnother(_entry.Memory, status.ProcessId) + }; StatusShape = CellFaces.StatusShape(_entry.Status); @@ -356,6 +361,22 @@ internal bool Absorb(ScmEntry entry, DateTimeOffset now) private static bool SameProcess(Reading left, Reading right) => left.Outcome == right.Outcome && left.Value == right.Value; + /// + /// The memory of an entry whose process has just changed - since 2026-10-27, logic report R07. Until + /// then the tick moved the status and the process and left the figure: a service stopped, or started + /// again in another process, went on showing the old process's megabytes, and a query on memory judged + /// it on them. + /// + /// No process now is what says of a stopped entry, so it is said here + /// too. Another process was never read, so it is not read - which lets the panel, and the list + /// when it holds memory for every row, ask for it. Never read before stays never read: nothing + /// was shown that could be wrong. + /// + private static Reading MemoryOfAnother(Reading memory, Reading process) => + memory.Outcome == ReadOutcome.NotRead ? memory + : process.IsPresent ? Reading.NotRead() + : Reading.Absent(); + // A Describe helper stood here until 2026-09-15, wrapping CellFaces.Say for the one caller // left - the account. The account now goes through SystemAccounts.Shown, which is Say with // the known names in front of it, so the helper had no caller and went the way its own diff --git a/src/Bws.Gui/ViewModels/HistoryExporting.cs b/src/Bws.Gui/ViewModels/HistoryExporting.cs index ba157867..1deca8cb 100644 --- a/src/Bws.Gui/ViewModels/HistoryExporting.cs +++ b/src/Bws.Gui/ViewModels/HistoryExporting.cs @@ -15,11 +15,13 @@ namespace Bws.Gui.ViewModels; /// on which four things changed. Then an empty line, then the table. /// /// The table as it stands on screen - the lines "Show" and the search leave, grouped or each on -/// its own the way the table has them, in the order somebody sorted them into. Two columns differ from -/// the screen, both on purpose. Times is a number on every line, where the screen leaves one +/// its own the way the table has them, in the order somebody sorted them into. Three things differ from +/// the screen, all on purpose. Times is a number on every line, where the screen leaves one /// blank because "1" in a column of counts is noise: a spreadsheet sums and filters numbers, and a blank /// is not one. The account's SID is a column of its own: an account's name is translated, or not -/// known at all, and the SID is what the account is (rule 3 of the project notes). +/// known at all, and the SID is what the account is (rule 3 of the project notes). Both times carry +/// their offset, since 2026-10-27 (logic report H04): a file outlives the screen it came from, and in +/// the hour the clocks go back two changes an hour apart read as one moment without it. /// /// Every protection the list export has, by calling the same code - Exporting.Line, for the /// reason DiffExporting gives: a display name is written by whoever installed the service. @@ -62,7 +64,15 @@ internal static string AsCsv(HistorySheet sheet, IReadOnlyList rows, { Exporting.Line( text, - [row.When, row.Count.ToString(CultureInfo.InvariantCulture), row.Since, row.Entry, row.Change, row.By, row.Group.Latest.By?.Sid ?? string.Empty], + [ + HistoryWords.Exact(row.At), + row.Count.ToString(CultureInfo.InvariantCulture), + row.FirstAt is null ? string.Empty : HistoryWords.Exact(row.FirstAt), + row.Entry, + row.Change, + row.By, + row.Group.Latest.By?.Sid ?? string.Empty + ], separator); } diff --git a/src/Bws.Gui/ViewModels/HistoryFilter.cs b/src/Bws.Gui/ViewModels/HistoryFilter.cs index 5c34f5dc..7ef87dfc 100644 --- a/src/Bws.Gui/ViewModels/HistoryFilter.cs +++ b/src/Bws.Gui/ViewModels/HistoryFilter.cs @@ -260,12 +260,17 @@ .. all.Select(change => RowSearch.Said( /// /// Whose and what, after every change: the changes SYSTEM did not make, installations, startup type /// changes. Which of them are offered is 's rule, the comparison's too. + /// + /// "Not by SYSTEM" takes only a change whose account the log names - since 2026-10-27, logic + /// report H03. A change the log records without an account may have been SYSTEM's, so leaving it in + /// would make a missing name read as a finding about who did it. It stays under every change, and the + /// line over the table says the choice left changes out. /// private List WhoseOf(IReadOnlyList all) => _whose.Offered( all, Texts.Of("gui.history.show.all"), [ - (Texts.Of("gui.history.show.notSystem"), change => !string.Equals(change.By?.Sid, SystemSid, StringComparison.Ordinal)), + (Texts.Of("gui.history.show.notSystem"), change => change.By is { } by && !string.Equals(by.Sid, SystemSid, StringComparison.Ordinal)), (Texts.Of("gui.history.show.installed"), change => change.Kind == ChangeKind.Installed), (Texts.Of("gui.history.show.startType"), change => change.Kind == ChangeKind.StartTypeChanged) ]); diff --git a/src/Bws.Gui/ViewModels/HistoryReading.AfterPlan.cs b/src/Bws.Gui/ViewModels/HistoryReading.AfterPlan.cs new file mode 100644 index 00000000..8c2cdbb7 --- /dev/null +++ b/src/Bws.Gui/ViewModels/HistoryReading.AfterPlan.cs @@ -0,0 +1,86 @@ +using Bws.Core.History; + +namespace Bws.Gui.ViewModels; + +/// +/// The change history read again after the window's own plan - logic report H01, 2026-10-27. A seam the +/// size ratchet asked for, cut by subject: what the log owes the window, and how long it takes to pay. +/// +public sealed partial class HistoryReading +{ + /// + /// How long the window gives the System log to show a change Windows has just made, before it reads + /// the log after its own plan. + /// + /// Measured, not guessed, and the measurement changed the fix. tools/history-probe/event-lag.ps1 + /// changed one service's start type with sc.exe and queried the log from the moment sc.exe returned: + /// the event 7040 appeared after 1000-1029 ms, six times of six, and never on the first query. The log + /// is fed by an event tracing session that hands its events over once a second (its FlushTimer is 1 on + /// that machine, and Microsoft gives one second as the least such a session flushes at). So reading + /// the log at once after a plan read it before the plan's own change was in it. + /// + /// Two periods, not one - a margin for a machine busy at that moment. A machine configured to + /// flush less often shows the change at the next reading, F5 or the view opened again, and that is the + /// price of waiting a fixed time rather than watching the log. + /// + internal static readonly TimeSpan LogWrites = TimeSpan.FromSeconds(2); + + /// + /// The window changed the machine since the reading out right now began, so the log owes it a change + /// that reading cannot hold. Read and written on the window's thread only. + /// + private bool _owed; + + /// + /// The window's own plan has run: the log again once it has had time to hold what the plan did, but only + /// once somebody has asked for it. Until 2026-10-27 a plan read the machine again and left the history + /// as it was, so the panel showed a start type next to a history without the change that set it. Not + /// awaited by the end of the run - the reading is kept in . + /// + /// A reading already out when the plan ended began before the plan's change reached the log, so + /// it is followed by one more rather than taken as the answer. + /// + internal void AfterOwnChange() + { + if (!_asked) + { + return; + } + + _owed = true; + + if (Out.IsCompleted) + { + Out = ReadAsync(); + } + } + + /// + /// The log read once, and again for as long as the window's own plan owes it a change the reading + /// began too early to hold - one task, so whoever awaits awaits the answer that holds it. + /// What one reading does and the failures it has are at . + /// + private async Task ReadAsync() + { + do + { + var owed = _owed; + + _owed = false; + + await ReadOnceAsync(owed).ConfigureAwait(true); + } + while (_owed); + } + + /// On the pool thread the reading runs on, from start to finish - the wait first, when it is owed. + private ChangeHistory Read(IChangeHistoryReader reader, bool owed) + { + if (owed) + { + _clock.Wait(LogWrites); + } + + return reader.Read(); + } +} diff --git a/src/Bws.Gui/ViewModels/HistoryReading.cs b/src/Bws.Gui/ViewModels/HistoryReading.cs index edb5e359..f6b1ed71 100644 --- a/src/Bws.Gui/ViewModels/HistoryReading.cs +++ b/src/Bws.Gui/ViewModels/HistoryReading.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.IO; using System.Xml; +using Bws.Core; using Bws.Core.History; using Bws.Core.Snapshots; @@ -50,9 +51,10 @@ internal interface IEntryHistory /// ONE READING AT A TIME. A second ask while one is out waits for that one, rather than reading the /// log twice for one question - the rule Snapshotting.Busy keeps for the same reason. /// -public sealed class HistoryReading : Observable, IEntryHistory +public sealed partial class HistoryReading : Observable, IEntryHistory { private readonly IChangeHistoryReader? _reader; + private readonly IClock _clock; private readonly Func? _says; private ChangeHistory? _latest; private string _failure = string.Empty; @@ -67,10 +69,12 @@ public sealed class HistoryReading : Observable, IEntryHistory private (ChangeHistory History, string Name, DetailSection Section)? _made; /// The log, or nothing for a model built without one - a test's state, which says so. + /// What the wait for the log is measured on, so a test does not wait it (). /// The line under the window, for what an export did - Snapshotting is handed the same. - internal HistoryReading(IChangeHistoryReader? reader, Func? says = null) + internal HistoryReading(IChangeHistoryReader? reader, IClock clock, Func? says = null) { _reader = reader; + _clock = clock; _says = says; } @@ -239,7 +243,8 @@ private Task Again() /// fault nobody foresaw, and it goes where those go - Mishaps - rather than into a sentence that /// would pretend to know what happened. /// - private async Task ReadAsync() + /// Wait first, off the window's thread, for a change the window just made. + private async Task ReadOnceAsync(bool owed) { // A NEW READING IS NOT THE LAST ONE'S FAILURE - since a review of 2026-10-20 (#81). Kept, the panel // went on saying the log could not be read while the view beside it said it was reading. Cleared @@ -252,7 +257,7 @@ private async Task ReadAsync() try { _latest = _reader is { } reader - ? await Task.Run(reader.Read).ConfigureAwait(true) + ? await Task.Run(() => Read(reader, owed)).ConfigureAwait(true) : throw new InvalidDataException(Texts.Of("gui.history.noReader")); } catch (Win32Exception failure) diff --git a/src/Bws.Gui/ViewModels/HistoryRow.cs b/src/Bws.Gui/ViewModels/HistoryRow.cs index 4e03f9f2..4a120df9 100644 --- a/src/Bws.Gui/ViewModels/HistoryRow.cs +++ b/src/Bws.Gui/ViewModels/HistoryRow.cs @@ -20,17 +20,30 @@ internal HistoryRow(ChangeGroup group) ArgumentNullException.ThrowIfNull(group); Group = group; - When = HistoryWords.When(group.Last ?? group.Latest.At); + At = group.Last ?? group.Latest.At; + FirstAt = group.Times > 1 ? group.First : null; + When = HistoryWords.When(At); Times = group.Times > 1 ? group.Times.ToString(CultureInfo.CurrentCulture) : string.Empty; Entry = HistoryWords.Entry(group.Latest); Change = HistoryWords.What(group.Latest); By = HistoryWords.Who(group.Latest.By); - Since = group.Times > 1 ? HistoryWords.When(group.First) : string.Empty; + Since = FirstAt is null ? string.Empty : HistoryWords.When(FirstAt); } /// When - the last time, for a group. public string When { get; } + /// + /// The moment behind , which the table sorts by - since 2026-10-27, logic report H04. + /// The text is this machine's local time without its offset, so the hour after the clocks go back + /// sorts before the hour it repeats, and two changes in it read as one moment. An instant does not. + /// Null when the log recorded no time, which sorts as the oldest, where the text "(not recorded)" sorted. + /// + public DateTimeOffset? At { get; } + + /// The moment behind , for the same reason - null for a change recorded once. + public DateTimeOffset? FirstAt { get; } + /// How many changes the line stands for, and empty for one - "1" in a column of counts is noise. public string Times { get; } diff --git a/src/Bws.Gui/ViewModels/HistoryWords.cs b/src/Bws.Gui/ViewModels/HistoryWords.cs index 5efab63b..3d6f1da5 100644 --- a/src/Bws.Gui/ViewModels/HistoryWords.cs +++ b/src/Bws.Gui/ViewModels/HistoryWords.cs @@ -32,6 +32,15 @@ at is { } time ? time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) : Texts.Of("gui.history.notRecorded"); + /// + /// The time with its offset from UTC, for a file - and then the offset, so a person + /// reads the same time the screen shows and a spreadsheet can still place it (H04, 2026-10-27). + /// + internal static string Exact(DateTimeOffset? at) => + at is { } time + ? time.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture) + : Texts.Of("gui.history.notRecorded"); + /// The account's name when this machine could give one, and the SID itself otherwise. internal static string Who(Actor? by) => Printable.Of(by?.Name ?? by?.Sid ?? Texts.Of("gui.history.notRecorded")); @@ -79,6 +88,12 @@ internal static IReadOnlyList Admissions(ChangeHistory history, IReadOnl lines.Add(Texts.Of("gui.history.log.full")); } + // An archived log keeps its past in other files whatever its size is now - H02, the command line's rule. + if (history.Log.Mode == LogMode.AutoBackup) + { + lines.Add(Texts.Of("gui.history.log.autoBackup")); + } + if (!oneEntry && history.HasInstallations) { lines.Add(Texts.Of("gui.history.installedUnderDisplayName")); @@ -110,8 +125,8 @@ internal static IReadOnlyList Admissions(ChangeHistory history, IReadOnl } /// - /// How far back the log reaches. "Has lost nothing since" only when nothing says otherwise - the - /// command line's rule, and the reason it is a sentence of its own. + /// How far back the log reaches, and only that - the command line's rule. "And has lost nothing since" + /// went on 2026-10-27 (H02): nothing about a log says it was written to without a gap. /// private static string Reach(LogCoverage log) { @@ -127,9 +142,7 @@ private static string Reach(LogCoverage log) : Texts.Of("gui.history.log.cleared", When(oldest), Who(cleared.By)); } - return log.Overwritten || log.Full - ? Texts.Of("gui.history.log.reaches", When(oldest)) - : Texts.Of("gui.history.log.since", When(oldest)); + return Texts.Of("gui.history.log.reaches", When(oldest)); } /// diff --git a/src/Bws.Gui/ViewModels/MainViewModel.History.cs b/src/Bws.Gui/ViewModels/MainViewModel.History.cs index db05dbc2..774302c2 100644 --- a/src/Bws.Gui/ViewModels/MainViewModel.History.cs +++ b/src/Bws.Gui/ViewModels/MainViewModel.History.cs @@ -1,3 +1,4 @@ +using Bws.Core; using Bws.Core.History; namespace Bws.Gui.ViewModels; @@ -33,9 +34,9 @@ internal Task OpenHistory() /// about the list is said again, because whether it may speak has just changed - the arrangement /// makes for a comparison. /// - private HistoryReading HistoryOf(IChangeHistoryReader? reader) + private HistoryReading HistoryOf(IChangeHistoryReader? reader, IClock clock) { - var history = new HistoryReading(reader, () => Says); + var history = new HistoryReading(reader, clock, () => Says); history.PropertyChanged += (_, changed) => { diff --git a/src/Bws.Gui/ViewModels/MainViewModel.cs b/src/Bws.Gui/ViewModels/MainViewModel.cs index 64f30c5f..cf5aa3f8 100644 --- a/src/Bws.Gui/ViewModels/MainViewModel.cs +++ b/src/Bws.Gui/ViewModels/MainViewModel.cs @@ -131,7 +131,7 @@ public MainViewModel( _index = new RowIndex(clock); _catalog = catalog; _processes = processes; - History = HistoryOf(history); + History = HistoryOf(history, clock); // Says is fetched rather than handed over, because a caller may replace it after this // constructor has run - see the argument on Readings._look. The last argument is fetched @@ -345,8 +345,17 @@ public bool Interacting /// The owner's S-1 decision, 2026-09-28, recorded in `ADR-13`. A plan writes no file and /// no path, so verifying every signature again afterwards could not give a new answer and cost /// seconds on a small machine. F5 stays and verifies everything. + /// + /// And the change history again, once the log has had time to hold the plan's change - logic + /// report H01, 2026-10-27. Not part of the task returned: the end of a run does not wait the seconds the + /// log takes, and the reading says it is reading meanwhile (). /// - public Task LoadKeepingAsync() => ReadMachineAsync(Relisting.Keeping); + public Task LoadKeepingAsync() + { + History.AfterOwnChange(); + + return ReadMachineAsync(Relisting.Keeping); + } private async Task ReadMachineAsync(Relisting how) { diff --git a/src/Bws.Gui/ViewModels/Narrowing.cs b/src/Bws.Gui/ViewModels/Narrowing.cs index 1b32f437..43c69aa6 100644 --- a/src/Bws.Gui/ViewModels/Narrowing.cs +++ b/src/Bws.Gui/ViewModels/Narrowing.cs @@ -49,16 +49,23 @@ internal static class Narrowing /// 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". /// + /// + /// One walk, one budget - since 2026-10-27, logic report R10. Each row was its own question, so a + /// pattern the linear engine would not take had fifty milliseconds per row and the window stood still + /// for their sum - 37.5 s over 804 entries from the terminal. Through one pass, the costly matches of + /// the whole walk share about 200 ms, and the rows past that say they were never checked. + /// public static Narrowed Of(Query query, IReadOnlyList everything) { var selected = new List(everything.Count); var unreadable = 0; var tooCostly = 0; var unvouched = 0; + var pass = query.Pass(); foreach (var row in everything) { - var match = query.Match(row.Entry); + var match = pass.Match(row.Entry); if (match.Matched) { diff --git a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs index 9ff2e717..bd714849 100644 --- a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs +++ b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs @@ -45,6 +45,14 @@ internal sealed partial class Readings : IEntryReads /// What the panel has asked for, per entry, since the index last took a whole list. private readonly Dictionary _claimed = new(StringComparer.OrdinalIgnoreCase); + /// + /// Which process each claim on memory was made for - since 2026-10-27, logic report R07. A claim on + /// one process's memory is no claim on the next one's: the tick moves a row to a new process and + /// leaves its memory unread (), and a claim + /// that still stood would keep the panel from asking for it until the next whole listing. + /// + private readonly Dictionary> _memoryFor = new(StringComparer.OrdinalIgnoreCase); + /// /// Why the panel's reading of an entry failed, per entry, for as long as its claim stands. /// @@ -71,7 +79,7 @@ public ExtraRead Claim(EntryRow row) ArgumentNullException.ThrowIfNull(row); var entry = row.Entry; - var claimed = _claimed.GetValueOrDefault(row.ServiceName); + var claimed = Claimed(row); var missing = (entry.Signature.Outcome == ReadOutcome.NotRead ? ExtraRead.Signatures : ExtraRead.None) @@ -82,12 +90,66 @@ public ExtraRead Claim(EntryRow row) if (owed != ExtraRead.None) { - _claimed[row.ServiceName] = claimed | owed; + Stake(row, claimed | owed); } return owed; } + /// + /// The list holds every row's memory, and the tick has just moved rows to another process - read + /// theirs now rather than leaving "not read" in a column somebody turned on until F5 (R07, 2026-10-27). + /// The panel's own path, one row at a time: a tick moves a handful of rows at most, and a pass over + /// the listing costs 5-8 ms (MemoryPass). A row whose memory is already being read is not asked twice. + /// + /// Inside the tick, awaited, the way the second phase runs inside a reading - so the list is + /// narrowed once, with the new figures, and no read is started and walked away from. + /// + private Task MemoryAgainForMovedAsync() + { + if (!_have.HasFlag(ExtraRead.Memory) || _reader is null) + { + return Task.CompletedTask; + } + + var reads = new List(); + + foreach (var row in _index.Ordered) + { + var claimed = Claimed(row); + + if (row.Entry.Memory.Outcome == ReadOutcome.NotRead && !claimed.HasFlag(ExtraRead.Memory)) + { + Stake(row, claimed | ExtraRead.Memory); + reads.Add(ReadAsync(row, ExtraRead.Memory)); + } + } + + return Task.WhenAll(reads); + } + + /// What has been claimed for this row - less a claim on memory made for another process (R07). + private ExtraRead Claimed(EntryRow row) + { + var claimed = _claimed.GetValueOrDefault(row.ServiceName); + + return claimed.HasFlag(ExtraRead.Memory) + && _memoryFor.TryGetValue(row.ServiceName, out var process) + && !(process.Outcome == row.Entry.ProcessId.Outcome && process.Value == row.Entry.ProcessId.Value) + ? claimed & ~ExtraRead.Memory + : claimed; + } + + private void Stake(EntryRow row, ExtraRead claimed) + { + _claimed[row.ServiceName] = claimed; + + if (claimed.HasFlag(ExtraRead.Memory)) + { + _memoryFor[row.ServiceName] = row.Entry.ProcessId; + } + } + /// public async Task ReadAsync(EntryRow row, ExtraRead families) { @@ -154,6 +216,7 @@ private void TookAWholeList() { _listing++; _claimed.Clear(); + _memoryFor.Clear(); _failures.Clear(); } diff --git a/src/Bws.Gui/ViewModels/Readings.cs b/src/Bws.Gui/ViewModels/Readings.cs index a10373df..378b5d6b 100644 --- a/src/Bws.Gui/ViewModels/Readings.cs +++ b/src/Bws.Gui/ViewModels/Readings.cs @@ -415,6 +415,7 @@ private async Task Tick() break; case Freshening.Moved: + await MemoryAgainForMovedAsync().ConfigureAwait(true); _settled(); break; diff --git a/tests/Bws.Architecture.Tests/SilentCatchGuards.cs b/tests/Bws.Architecture.Tests/SilentCatchGuards.cs index e327c1bb..08e02c10 100644 --- a/tests/Bws.Architecture.Tests/SilentCatchGuards.cs +++ b/tests/Bws.Architecture.Tests/SilentCatchGuards.cs @@ -47,7 +47,10 @@ public sealed class SilentCatchGuards // own on 2026-10-05 (stability report R-3), taking its one catch with it. ["src/Bws.Core/ManagerEnvironment.cs"] = 1, ["src/Bws.Core/Querying/QueryPatterns.cs"] = 1, - ["src/Bws.Core/Querying/QueryValues.cs"] = 1, + + // 2026-10-27, logic report R10: the catch of a pattern running out of time moved, unchanged, from + // QueryValues.cs into the walk's budget, which now asks the pattern. Ran out of time is the answer. + ["src/Bws.Core/Querying/QueryBudget.cs"] = 1, ["src/Bws.Core/Snapshots/AtomicFile.cs"] = 2, // 2026-09-30, stability report D-5, owner's decision that day: the monthly update number diff --git a/tests/Bws.Cli.Tests/HistoryOutputTests.cs b/tests/Bws.Cli.Tests/HistoryOutputTests.cs index ba502060..ab5d8c1e 100644 --- a/tests/Bws.Cli.Tests/HistoryOutputTests.cs +++ b/tests/Bws.Cli.Tests/HistoryOutputTests.cs @@ -26,22 +26,37 @@ public void A_cleared_log_says_when_and_who() } [Fact] - public void A_log_that_lost_nothing_says_so_and_one_that_did_does_not() + public void No_log_is_promised_to_have_lost_nothing_and_one_that_loses_says_which_end() { + // H02, 2026-10-27: nothing about a log proves it was written to without a gap, so the first + // sentence says how far back it reaches and stops there. var whole = HistoryText.Admissions(History(Log()), [], serviceName: null); var overwritten = HistoryText.Admissions(History(Log(overwritten: true)), [], serviceName: null); - var full = HistoryText.Admissions(History(Log(full: true)), [], serviceName: null); + var full = HistoryText.Admissions(History(Log(full: true) with { Mode = LogMode.Retain }), [], serviceName: null); - Assert.Contains("has lost nothing since", whole[0], StringComparison.Ordinal); + Assert.Equal( + "The System log reaches back to " + + Cleared.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + ".", + whole[0]); + Assert.DoesNotContain(whole, line => line.Contains("lost nothing", StringComparison.Ordinal)); - Assert.DoesNotContain("lost nothing", overwritten[0], StringComparison.Ordinal); Assert.Contains(overwritten, line => line.Contains("anything older is gone", StringComparison.Ordinal)); // The other end missing: a full log that keeps what it has is not writing the newest. - Assert.DoesNotContain("lost nothing", full[0], StringComparison.Ordinal); Assert.Contains(full, line => line.Contains("latest changes are missing", StringComparison.Ordinal)); } + [Fact] + public void An_archived_log_says_older_events_may_be_in_files_bws_does_not_read() + { + // H02: said whatever its size, because the archives are there from the first time it filled. + var archived = HistoryText.Admissions(History(Log() with { Mode = LogMode.AutoBackup }), [], serviceName: null); + var circular = HistoryText.Admissions(History(Log()), [], serviceName: null); + + Assert.Contains(archived, line => line.Contains("archive files", StringComparison.Ordinal)); + Assert.DoesNotContain(circular, line => line.Contains("archive", StringComparison.Ordinal)); + } + [Fact] public void An_empty_log_is_called_empty_rather_than_given_a_date() { @@ -125,7 +140,7 @@ public void The_document_holds_every_field_in_every_change_with_the_log_first() Assert.Equal(["log", "changes"], root.EnumerateObject().Select(property => property.Name)); Assert.Equal( - ["name", "reachesBack", "overwritten", "full", "cleared"], + ["name", "reachesBack", "overwritten", "full", "cleared", "mode"], root.GetProperty("log").EnumerateObject().Select(property => property.Name)); string[] fields = ["at", "kind", "serviceName", "displayName", "by", "binaryPath", "account", "windowsWords"]; @@ -197,13 +212,15 @@ public void Two_names_and_a_switch_of_another_verb_are_refused_before_anything_i Assert.Null(Refusals.Answer(CommandLine.Read(["history", "Spooler", "--json", "--timing"]))); } + /// A circular log - another mode is Log() with { Mode = ... }. private static LogCoverage Log(bool cleared = false, bool overwritten = false, bool full = false) => new() { Name = "System", ReachesBack = Cleared, Overwritten = overwritten, Full = full, - Cleared = cleared ? new Clearing(Cleared, new Actor(Person, "HOST\\USER")) : null + Cleared = cleared ? new Clearing(Cleared, new Actor(Person, "HOST\\USER")) : null, + Mode = LogMode.Circular }; private static ChangeHistory History(LogCoverage log, params ServiceChange[] changes) => new(log, changes); diff --git a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs index e1b72f06..e3e88be3 100644 --- a/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs +++ b/tests/Bws.Cli.Tests/MachineDocumentValueGuards.cs @@ -85,7 +85,7 @@ public void The_kind_of_every_change_in_the_history_is_a_member_spelled_as_its_e .Select(kind => new ServiceChange { At = null, Kind = kind }) .ToList(); - var log = ChangeHistory.Cover("System", new LogFacts(0, 1, Full: false), oldest: null, cleared: null); + var log = ChangeHistory.Cover("System", new LogFacts(0, 1, Full: false, LogMode.Circular), oldest: null, cleared: null); var written = JsonDocument.Parse(HistoryJson.Render(log, changes)).RootElement .GetProperty("changes").EnumerateArray() @@ -94,6 +94,21 @@ public void The_kind_of_every_change_in_the_history_is_a_member_spelled_as_its_e Assert.Equal(changes.Count, written); } + [Fact] + public void The_mode_of_the_log_is_a_member_spelled_as_its_enumeration_spells_it_and_the_last_key() + { + // H02, 2026-10-27: added at the end of "log", so a script reading the keys before it reads the same. + foreach (var mode in Enum.GetValues()) + { + var log = ChangeHistory.Cover("System", new LogFacts(0, 1, Full: false, mode), oldest: null, cleared: null); + var written = JsonDocument.Parse(HistoryJson.Render(log, [])).RootElement.GetProperty("log"); + + Assert.Equal(1, Member(written, "mode")); + Assert.Equal(mode.ToString(), written.GetProperty("mode").GetString()); + Assert.Equal("mode", written.EnumerateObject().Last().Name); + } + } + /// /// A run of one plan holding a step of every operation, every reason, a warning of every kind and /// a result of every outcome and every skip reason. diff --git a/tests/Bws.Core.Tests/CatalogueRescueTests.cs b/tests/Bws.Core.Tests/CatalogueRescueTests.cs new file mode 100644 index 00000000..ee0bea2f --- /dev/null +++ b/tests/Bws.Core.Tests/CatalogueRescueTests.cs @@ -0,0 +1,56 @@ +namespace Bws.Core.Tests; + +/// +/// What the second catalogue question, asked with SHA-1, may answer - logic report C03, owner's decision of +/// 2026-10-26 "two cases apart". A catalogue found and then not verified is a refusal with Windows' number. +/// No catalogue, or no way to ask, is "not signed" as before. +/// +/// The rule without Windows, because no machine here has a catalogue that fails once found - the +/// owner's has no file only an SHA-1 catalogue names at all (R-6, 2026-10-05). +/// +public sealed class CatalogueRescueTests +{ + private const int TrustFailure = unchecked((int)0x80096010); + private const int NoSignature = unchecked((int)0x800B0100); + + [Fact] + public void A_catalogue_found_and_not_verified_is_a_refusal_with_its_number() + { + var refused = Reading.Denied(TrustFailure, "The digital signature of the object did not verify."); + + var answer = WindowsBinaryInspector.AfterSha1(refused, listed: true); + + Assert.Equal(ReadOutcome.Denied, answer.Outcome); + Assert.Equal(TrustFailure, answer.ErrorCode); + } + + [Fact] + public void A_catalogue_found_and_verified_is_its_verdict() + { + var signed = Reading.Present(new BinarySignature(SignatureStatus.Trusted, 0, Publisher: "Microsoft Windows")); + + Assert.Equal(signed, WindowsBinaryInspector.AfterSha1(signed, listed: true)); + } + + [Fact] + public void No_catalogue_is_not_signed() + { + var answer = WindowsBinaryInspector.AfterSha1(null, listed: false); + + Assert.Equal(SignatureStatus.NotSigned, answer.Value!.Status); + Assert.Equal(NoSignature, answer.Value.ResultCode); + } + + [Fact] + public void A_machine_that_will_not_ask_with_sha1_is_not_signed_as_before() + { + // Refused before any catalogue was looked for - the context, or the hash - so nothing is known + // about a catalogue, and the SHA-256 question already answered that the file is in none. + var refused = Reading.Denied(TrustFailure, "SHA-1 is not available."); + + var answer = WindowsBinaryInspector.AfterSha1(refused, listed: false); + + Assert.Equal(ReadOutcome.Present, answer.Outcome); + Assert.Equal(SignatureStatus.NotSigned, answer.Value!.Status); + } +} diff --git a/tests/Bws.Core.Tests/ChangeHistoryTests.cs b/tests/Bws.Core.Tests/ChangeHistoryTests.cs index 8b8d4e13..9bfb159d 100644 --- a/tests/Bws.Core.Tests/ChangeHistoryTests.cs +++ b/tests/Bws.Core.Tests/ChangeHistoryTests.cs @@ -16,27 +16,39 @@ public sealed class ChangeHistoryTests public void A_log_with_room_left_is_not_overwriting() { // The owner's System log, 2026-10-15: 1 118 208 bytes of a 20 MB limit. - var log = ChangeHistory.Cover("System", new LogFacts(1_118_208, Limit, Full: false), At(), cleared: null); + var log = ChangeHistory.Cover("System", new LogFacts(1_118_208, Limit, Full: false, LogMode.Circular), At(), cleared: null); Assert.False(log.Overwritten); Assert.False(log.Full); + Assert.Equal(LogMode.Circular, log.Mode); } [Fact] public void A_log_at_its_limit_is_overwriting() { // The owner's Security log the same day: a 4096 byte header plus the whole limit, overwriting. - var log = ChangeHistory.Cover("System", new LogFacts(Limit + 4096, Limit, Full: false), At(), cleared: null); + var log = ChangeHistory.Cover("System", new LogFacts(Limit + 4096, Limit, Full: false, LogMode.Circular), At(), cleared: null); Assert.True(log.Overwritten); } + [Fact] + public void An_archived_log_at_its_limit_is_not_called_overwritten() + { + // H02: Windows puts the full file aside and begins a new one, and documents EvtLogFull as always + // false for such a log - which until 2026-10-27 made "not full and at the limit" read as overwriting. + var log = ChangeHistory.Cover("System", new LogFacts(Limit + 4096, Limit, Full: false, LogMode.AutoBackup), At(), cleared: null); + + Assert.False(log.Overwritten); + Assert.Equal(LogMode.AutoBackup, log.Mode); + } + [Fact] public void A_log_with_less_than_one_chunk_of_room_is_already_at_its_limit() { // The file grows a chunk at a time, so the last chunk that does not fit is the limit. - Assert.True(new LogFacts(Limit - LogFacts.Chunk + 1, Limit, Full: false).AtSizeLimit); - Assert.False(new LogFacts(Limit - LogFacts.Chunk, Limit, Full: false).AtSizeLimit); + Assert.True(new LogFacts(Limit - LogFacts.Chunk + 1, Limit, Full: false, LogMode.Circular).AtSizeLimit); + Assert.False(new LogFacts(Limit - LogFacts.Chunk, Limit, Full: false, LogMode.Circular).AtSizeLimit); } [Fact] @@ -44,7 +56,7 @@ public void A_full_log_is_not_called_overwritten() { // Full means Windows keeps what it has and drops the new - the opposite end of the history is // missing, and saying "anything older is gone" too would be untrue. - var log = ChangeHistory.Cover("System", new LogFacts(Limit + 4096, Limit, Full: true), At(), cleared: null); + var log = ChangeHistory.Cover("System", new LogFacts(Limit + 4096, Limit, Full: true, LogMode.Retain), At(), cleared: null); Assert.True(log.Full); Assert.False(log.Overwritten); @@ -125,7 +137,7 @@ public void System_is_named_by_this_machine() private static DateTimeOffset At() => new(2026, 10, 11, 14, 58, 5, TimeSpan.Zero); private static ChangeHistory History(params ServiceChange[] changes) => - new(ChangeHistory.Cover("System", new LogFacts(0, Limit, Full: false), At(), cleared: null), changes); + new(ChangeHistory.Cover("System", new LogFacts(0, Limit, Full: false, LogMode.Circular), At(), cleared: null), changes); private static ServiceChange Changed(string? name) => new() { At = At(), Kind = ChangeKind.StartTypeChanged, ServiceName = name, DisplayName = name }; diff --git a/tests/Bws.Core.Tests/QueryBudgetTests.cs b/tests/Bws.Core.Tests/QueryBudgetTests.cs new file mode 100644 index 00000000..a2481ed8 --- /dev/null +++ b/tests/Bws.Core.Tests/QueryBudgetTests.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; +using Bws.Core.Querying; + +namespace Bws.Core.Tests; + +/// +/// One walk over a listing has one budget for patterns the linear engine would not take - logic report R10, +/// owner's decision of 2026-10-26 "a budget for the whole query". Before it each entry had fifty milliseconds +/// of its own: 37.5 s over 804 entries from the terminal, and the window still for as long. +/// +/// A ruler handed in, not the clock, for every test but the last: what is held is which matches are +/// tried and which are not, and a stopwatch would also be measuring the machine (QueryPatterns says why). +/// +public sealed class QueryBudgetTests +{ + private static readonly TimeSpan Step = TimeSpan.FromMilliseconds(100); + + [Fact] + public void Once_the_walk_has_spent_its_budget_a_costly_pattern_is_not_tried_and_says_it_ran_out() + { + // Each match the ruler sees costs a hundred milliseconds, and the walk may spend two hundred. + var ruler = new Ruler(); + var pass = new QueryPass(QueryParserTests.Valid("name:/Spool(?=er)/"), new QueryBudget(ruler.Read, 2 * Step)); + + var matches = Enumerable.Range(0, 5).Select(_ => pass.Match(Entries.Named("Spooler", "Spooler"))).ToList(); + + Assert.Equal([true, true, false, false, false], matches.Select(match => match.Matched)); + Assert.Equal([false, false, true, true, true], matches.Select(match => match.TooCostly)); + + // Never checked means never asked: two matches, two readings of the ruler each. + Assert.Equal(4, ruler.Readings); + } + + [Fact] + public void The_linear_engine_is_never_charged_however_little_is_left() + { + // It cannot run away, so a walk spending its time there is a large listing, not a costly pattern. + var ruler = new Ruler(); + var pass = new QueryPass(QueryParserTests.Valid("name:/Spool/"), new QueryBudget(ruler.Read, TimeSpan.Zero)); + + var match = pass.Match(Entries.Named("Spooler", "Spooler")); + + Assert.True(match.Matched); + Assert.False(match.TooCostly); + Assert.Equal(0, ruler.Readings); + } + + [Fact] + public void Every_walk_begins_with_the_whole_budget() + { + // The window walks its rows again at every keystroke - a spent budget is not carried into the next. + var query = QueryParserTests.Valid("name:/Spool(?=er)/"); + + query.Pass().Match(Entries.Named("Spooler", "Spooler")); + + Assert.True(query.Pass().Match(Entries.Named("Spooler", "Spooler")).Matched); + } + + [Fact] + public void A_listing_filtered_at_once_shares_one_budget_rather_than_one_per_entry() + { + // The pattern runs away on every entry: a backreference sends it to the backtracking engine, and + // there each match takes its whole fifty milliseconds. Two hundred entries cost ten seconds with a + // budget each, and about a quarter of a second sharing one. Three seconds between them is a margin + // of more than ten either way - this does not measure speed, it tells the two arrangements apart. + var runOfAs = new string('a', 32); + var entries = Enumerable.Range(0, 200).Select(_ => Entries.Named(runOfAs, runOfAs)).ToList(); + var query = QueryParserTests.Valid(@"name:/(a+)+\1b/"); + + var watch = Stopwatch.StartNew(); + var result = query.Filter(entries); + + watch.Stop(); + + Assert.Equal(200, result.TooCostly); + Assert.Empty(result.Entries); + Assert.True( + watch.Elapsed < TimeSpan.FromSeconds(3), + "The walk took " + watch.ElapsedMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture) + " ms."); + } + + /// A ruler that moves a step every time it is read, and counts how often it was. + private sealed class Ruler + { + private TimeSpan _now; + + internal int Readings { get; private set; } + + internal TimeSpan Read() + { + Readings++; + _now += Step; + + return _now; + } + } +} diff --git a/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs b/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs new file mode 100644 index 00000000..100caa62 --- /dev/null +++ b/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs @@ -0,0 +1,70 @@ +using Bws.Core.History; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// The change history read again after the window's own plan - logic report H01, 2026-10-27. The panel +/// showed a start type beside a history without the change that set it, until F5. The log shows a change +/// about a second after it is made (tools/history-probe/event-lag.ps1), so the reading waits for it. +/// +public sealed class HistoryAfterPlanTests +{ + [Fact] + public async Task The_windows_own_plan_reads_the_history_again_once_the_log_has_had_time_to_hold_it() + { + var log = new HistoryLog(HistoryLog.Ordinary()); + var clock = new SteppedClock(); + var model = new MainViewModel(new LiveMachine(Rows.Entry("Spooler"), Rows.Entry("BITS")), clock, history: log); + + await model.OpenHistory(); + + var before = clock.Elapsed; + + log.History = new ChangeHistory( + HistoryLog.Log(), [.. HistoryLog.Ordinary().Changes.Prepend(HistoryLog.ByPerson("Spooler", HistoryLog.Day(21, 9), back: true))]); + + await model.LoadKeepingAsync(); + await model.History.Out; + + Assert.Equal(2, log.Reads); + Assert.True(clock.Elapsed - before >= HistoryReading.LogWrites, "the reading did not wait for the log"); + Assert.Equal(8, model.History.Shown!.Filter.Rows.Sum(row => row.Count)); + } + + [Fact] + public async Task A_plan_reads_no_history_nobody_asked_for() + { + var log = new HistoryLog(HistoryLog.Ordinary()); + var model = Model(log); + + await model.LoadKeepingAsync(); + await model.History.Out; + + Assert.Equal(0, log.Reads); + } + + [Fact] + public async Task A_reading_out_when_the_plan_ends_is_followed_by_one_that_holds_the_change() + { + // Begun before the plan's change reached the log, so it cannot be the answer to it. + var log = new HistoryLog(HistoryLog.Ordinary()); + var model = Model(log); + + await model.OpenHistory(); + + log.Hold(); + + var f5 = model.LoadAsync(); + + model.History.AfterOwnChange(); + log.Release(); + await f5; + await model.History.Out; + + Assert.Equal(3, log.Reads); + } + + private static MainViewModel Model(HistoryLog log) => + new(new LiveMachine(Rows.Entry("Spooler"), Rows.Entry("BITS")), new SteppedClock(), history: log); +} diff --git a/tests/Bws.Gui.Tests/HistoryClaimsTests.cs b/tests/Bws.Gui.Tests/HistoryClaimsTests.cs new file mode 100644 index 00000000..a76f8fbf --- /dev/null +++ b/tests/Bws.Gui.Tests/HistoryClaimsTests.cs @@ -0,0 +1,40 @@ +using Bws.Core.History; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// What the window's change history may claim about the log - logic report H02 and H03, 2026-10-27. A +/// missing account is not a finding about who made a change, and how far back a log reaches is not a +/// promise that nothing between then and now is missing. +/// +public sealed class HistoryClaimsTests +{ + [Fact] + public void A_change_with_no_account_recorded_is_not_counted_as_not_by_system() + { + // H03: the log may record a change without an account, and it may have been SYSTEM's. + var unknown = HistoryLog.ByPerson("Spooler", HistoryLog.Day(21, 9)) with { By = null }; + var filter = new HistoryFilter([.. HistoryLog.Ordinary().Changes.Prepend(unknown)]); + + Assert.Equal(Texts.Of("gui.history.show.counted", Texts.Of("gui.history.show.notSystem"), 3), filter.Groups[1][1].Label); + + filter.Groups[1][1].IsChosen = true; + + Assert.DoesNotContain(filter.Rows, row => row.Group.Latest.By is null); + } + + [Fact] + public void No_log_is_promised_to_have_lost_nothing_and_an_archived_one_says_where_its_past_is() + { + // H02, the command line's rule: the reach is said and nothing more, and an archived log keeps its + // older events in files the window does not read. + var circular = HistoryWords.Admissions(HistoryLog.Ordinary(), [], oneEntry: false); + var archived = HistoryWords.Admissions( + HistoryLog.Ordinary() with { Log = HistoryLog.Log() with { Mode = LogMode.AutoBackup } }, [], oneEntry: false); + + Assert.DoesNotContain(circular, line => line.Contains("lost nothing", StringComparison.Ordinal)); + Assert.DoesNotContain(Texts.Of("gui.history.log.autoBackup"), circular); + Assert.Contains(Texts.Of("gui.history.log.autoBackup"), archived); + } +} diff --git a/tests/Bws.Gui.Tests/HistoryExportingTests.cs b/tests/Bws.Gui.Tests/HistoryExportingTests.cs index b0992b56..1817fb9c 100644 --- a/tests/Bws.Gui.Tests/HistoryExportingTests.cs +++ b/tests/Bws.Gui.Tests/HistoryExportingTests.cs @@ -47,10 +47,17 @@ public void A_line_carries_its_count_as_a_number_and_the_account_by_sid() var lines = Lines(HistoryExporting.AsCsv(sheet, [spooler, bits], ",")); - Assert.Equal(Line(spooler.When, "1", string.Empty, "Spooler", spooler.Change, spooler.By, HistoryLog.PersonSid), lines[^3]); - Assert.Equal(Line(bits.When, "2", bits.Since, "BITS", bits.Change, bits.By, HistoryLog.SystemSid), lines[^2]); + Assert.Equal(Line(Exact(spooler.When, spooler.At), "1", string.Empty, "Spooler", spooler.Change, spooler.By, HistoryLog.PersonSid), lines[^3]); + Assert.Equal(Line(Exact(bits.When, bits.At), "2", Exact(bits.Since, bits.FirstAt), "BITS", bits.Change, bits.By, HistoryLog.SystemSid), lines[^2]); } + /// + /// The time the screen shows and then its offset from UTC - H04, so a file names one moment even in the + /// hour the clocks go back. Built from the screen's own text, so this does not restate the format. + /// + private static string Exact(string shown, DateTimeOffset? at) => + shown + " " + at!.Value.ToLocalTime().ToString("zzz", System.Globalization.CultureInfo.InvariantCulture); + [Fact] public void A_search_is_said_in_the_file_and_only_the_lines_it_leaves_go_in() { diff --git a/tests/Bws.Gui.Tests/HistoryLog.cs b/tests/Bws.Gui.Tests/HistoryLog.cs index 530c1b2a..8c2c9322 100644 --- a/tests/Bws.Gui.Tests/HistoryLog.cs +++ b/tests/Bws.Gui.Tests/HistoryLog.cs @@ -59,7 +59,7 @@ public ChangeHistory Read() internal static LogCoverage Log() => ChangeHistory.Cover( "System", - new LogFacts(2_166_784UL, 20_971_520UL, Full: false), + new LogFacts(2_166_784UL, 20_971_520UL, Full: false, LogMode.Circular), Day(11, 16), new Clearing(Day(11, 16), new Actor(PersonSid, @"PC\someone"))); diff --git a/tests/Bws.Gui.Tests/HistoryWindowGuards.cs b/tests/Bws.Gui.Tests/HistoryWindowGuards.cs index 2a0b883d..5a233ad3 100644 --- a/tests/Bws.Gui.Tests/HistoryWindowGuards.cs +++ b/tests/Bws.Gui.Tests/HistoryWindowGuards.cs @@ -113,12 +113,30 @@ public async Task The_history_opens_newest_first_with_the_mark_on_when_and_again // opening is a new sheet - so the mark has to come back each time, not once. var (window, model) = await Opened(); - Assert.Equal((nameof(HistoryRow.When), ListSortDirection.Descending, ListSortDirection.Descending), Sorted(window)); + Assert.Equal((nameof(HistoryRow.At), ListSortDirection.Descending, ListSortDirection.Descending), Sorted(window)); WpfHost.On(() => model.History.PutAway()); await Open(window, model); - Assert.Equal((nameof(HistoryRow.When), ListSortDirection.Descending, ListSortDirection.Descending), Sorted(window)); + Assert.Equal((nameof(HistoryRow.At), ListSortDirection.Descending, ListSortDirection.Descending), Sorted(window)); + } + + [Fact] + public async Task The_hour_the_clocks_go_back_is_sorted_by_when_it_happened() + { + // H04: 02:50 summer time comes BEFORE 02:10 winter time the same night - 00:50 and 01:10 UTC. In + // a zone with that night the local text of the first sorts after the second, which put the + // older change on top. The two moments are the same in every zone, so this holds in all of them. + var earlier = HistoryLog.ByPerson("Spooler", new DateTimeOffset(2026, 10, 25, 2, 50, 0, TimeSpan.FromHours(2))); + var later = HistoryLog.BySystem("BITS", new DateTimeOffset(2026, 10, 25, 2, 10, 0, TimeSpan.FromHours(1))); + var model = Model(new HistoryLog(new Bws.Core.History.ChangeHistory(HistoryLog.Log(), [earlier, later]))); + var window = WpfHost.Window(model); + + await Open(window, model); + + Assert.Equal( + ["BITS", "Spooler"], + WpfHost.On(() => window.ChangeHistory.Table.Lines.Items.Cast().Select(row => row.Entry).ToList())); } [Fact] diff --git a/tests/Bws.Gui.Tests/PanelReadingTests.cs b/tests/Bws.Gui.Tests/PanelReadingTests.cs index 9a1b570d..d13d9ba9 100644 --- a/tests/Bws.Gui.Tests/PanelReadingTests.cs +++ b/tests/Bws.Gui.Tests/PanelReadingTests.cs @@ -176,6 +176,64 @@ public async Task A_memory_read_for_a_process_that_has_since_restarted_is_read_a Assert.True(row.Entry.Signature.IsPresent, "The file's answer does not depend on the process and should have been kept."); } + /// + /// The figure belongs to the process it was read for - logic report R07, 2026-10-27. The tick moved + /// the status and the process and left the megabytes, so a stopped service went on "using" what its + /// old process used, and the panel did not ask again because its claim still stood. + /// + [Fact] + public async Task Memory_goes_with_its_process_and_the_panel_reads_the_next_ones() + { + var memory = new Memory(); + var machine = new LiveMachine(Entry()); + var model = new MainViewModel(machine, new SteppedClock(), new Inspector(), memory); + await model.LoadAsync(); + var row = model.Rows.Single(); + + await Open(model, row); + Assert.Equal(Memory.Of(1234), row.Entry.Memory.Value!.WorkingSet); + + machine.Stop("Spooler"); + await model.RefreshAsync(); + + Assert.Equal(ReadOutcome.Absent, row.Entry.Memory.Outcome); + + machine.Start("Spooler", 4321); + await model.RefreshAsync(); + + Assert.Equal(ReadOutcome.NotRead, row.Entry.Memory.Outcome); + + await Open(model, row); + + Assert.Equal(Memory.Of(4321), row.Entry.Memory.Value!.WorkingSet); + Assert.Equal(2, memory.Asked); + } + + /// + /// With memory held for every row - a column, or a question - a row moved to another process is read + /// again at once, rather than saying "not read" in a column somebody turned on until F5 (R07). + /// + [Fact] + public async Task A_list_holding_every_rows_memory_reads_a_restarted_one_at_once() + { + var memory = new Memory(); + var machine = new LiveMachine(Entry()); + var model = new MainViewModel(machine, new SteppedClock(), new Inspector(), memory); + await model.LoadAsync(); + model.QueryText = "memory:>1MB"; + await model.RefreshAsync(); + var row = model.Rows.Single(); + + Assert.True(row.Entry.Memory.IsPresent, "The memory pass never ran, so the rest of this proves nothing."); + + machine.Start("Spooler", 4321); + await model.RefreshAsync(); + + Assert.True( + SpinWait.SpinUntil(() => row.Entry.Memory is { IsPresent: true } read && read.Value!.WorkingSet == Memory.Of(4321), TimeSpan.FromSeconds(10)), + "The new process's memory was never read."); + } + /// /// A failure belongs to the entry that failed. Opening another entry while that reading is out /// must not dress the new one in it (review of PR #16, case one). diff --git a/tests/Bws.Gui.Tests/RowMemoryTests.cs b/tests/Bws.Gui.Tests/RowMemoryTests.cs new file mode 100644 index 00000000..b2bc922a --- /dev/null +++ b/tests/Bws.Gui.Tests/RowMemoryTests.cs @@ -0,0 +1,61 @@ +using Bws.Core; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// What a row's memory says once the tick has moved its process - logic report R07, 2026-10-27. The figure +/// is a measurement of one process, so it may not outlive the process it measured. +/// +public sealed class RowMemoryTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 27, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void A_stopped_entry_has_no_process_to_use_memory() + { + var row = EntryRow.Of(Measured(1234)); + + Assert.True(row.Absorb(new ScmStatus("Spooler", EntryStatus.Stopped, Reading.Absent()), Now)); + + // What MemoryPass says of an entry with no process - a fact, not a gap. + Assert.Equal(ReadOutcome.Absent, row.Entry.Memory.Outcome); + } + + [Fact] + public void Another_process_has_memory_nobody_has_read() + { + var row = EntryRow.Of(Measured(1234)); + + row.Absorb(new ScmStatus("Spooler", EntryStatus.Running, Reading.Present(4321)), Now); + + Assert.Equal(ReadOutcome.NotRead, row.Entry.Memory.Outcome); + } + + [Fact] + public void The_same_process_keeps_its_figure_whatever_its_state_does() + { + var row = EntryRow.Of(Measured(1234)); + + row.Absorb(new ScmStatus("Spooler", EntryStatus.StopPending, Reading.Present(1234)), Now); + + Assert.True(row.Entry.Memory.IsPresent); + } + + [Fact] + public void Memory_never_read_stays_never_read() + { + // A listing without the memory family read nobody's - a stop says nothing new about that. + var row = EntryRow.Of(Rows.Entry("Spooler")); + + row.Absorb(new ScmStatus("Spooler", EntryStatus.Stopped, Reading.Absent()), Now); + + Assert.Equal(ReadOutcome.NotRead, row.Entry.Memory.Outcome); + } + + private static ScmEntry Measured(int processId) => Rows.Entry("Spooler") with + { + ProcessId = Reading.Present(processId), + Memory = Reading.Present(new ProcessMemory(processId * 1024L, 2048, 1)) + }; +} diff --git a/tests/Bws.Integration.Tests/HistoryContractTests.cs b/tests/Bws.Integration.Tests/HistoryContractTests.cs index 428693c6..aab50fa1 100644 --- a/tests/Bws.Integration.Tests/HistoryContractTests.cs +++ b/tests/Bws.Integration.Tests/HistoryContractTests.cs @@ -74,6 +74,16 @@ public void Changes_come_newest_first_and_a_start_type_change_always_names_its_e change => Assert.False(string.IsNullOrEmpty(change.GetProperty("serviceName").GetString()))); } + [Fact] + public void The_mode_of_the_log_is_the_one_get_winevent_names() + { + // H02, 2026-10-27: read from the channel's configuration, so the second opinion is the column + // Get-WinEvent prints - which is also where the names come from (Circular, AutoBackup, Retain). + var theirs = CommandLineTool.PowerShell("(Get-WinEvent -ListLog System).LogMode.ToString()"); + + Assert.Equal(theirs.Trim(), Document().GetProperty("log").GetProperty("mode").GetString()); + } + private static JsonElement Document() { var run = CommandLineTool.Run("history", "--json"); diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index ab97f242..d20bb8d6 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -10011,8 +10011,8 @@ $mutations = @( @{ Name = 'kept files: the reading after a plan keeps what is known' File = 'src\Bws.Gui\ViewModels\MainViewModel.cs' - From = 'public Task LoadKeepingAsync() => ReadMachineAsync(Relisting.Keeping);' - To = 'public Task LoadKeepingAsync() => ReadMachineAsync(Relisting.Afresh);' + From = ' return ReadMachineAsync(Relisting.Keeping);' + To = ' return ReadMachineAsync(Relisting.Afresh);' Test = 'KeptFileAnswersTests.A_plan_keeps_what_the_window_knows_about_files' }, @{ @@ -13082,7 +13082,7 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : @{ Name = 'history: a full log is not called overwritten' File = 'src\Bws.Core\History\ChangeHistory.cs' - From = 'Overwritten = !facts.Full && facts.AtSizeLimit,' + From = 'Overwritten = facts.Mode == LogMode.Circular && facts.AtSizeLimit,' To = 'Overwritten = facts.AtSizeLimit,' Test = 'ChangeHistoryTests.A_full_log_is_not_called_overwritten' } @@ -13124,11 +13124,12 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : Test = 'ChangeEventsTests.The_time_is_the_instant_windows_wrote_in_utc_to_the_full_precision' } @{ - Name = 'history text: a log that lost something never says it lost nothing' + # H02, 2026-10-27: the promise "has lost nothing since" went, and an archived log says where its past is. + Name = 'history text: an archived log says older events may be in archive files' File = 'src\Bws.Cli\HistoryText.cs' - From = 'return log.Overwritten || log.Full' - To = 'return log.Full' - Test = 'HistoryOutputTests.A_log_that_lost_nothing_says_so_and_one_that_did_does_not' + From = 'if (history.Log.Mode == LogMode.AutoBackup)' + To = 'if (history.Log.Mode == LogMode.Retain)' + Test = 'HistoryOutputTests.An_archived_log_says_older_events_may_be_in_files_bws_does_not_read' } @{ # A misspelt name must not read as "nobody touched it". @@ -13649,10 +13650,18 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : @{ Name = 'window history: not by SYSTEM leaves SYSTEM out' File = 'src\Bws.Gui\ViewModels\HistoryFilter.cs' - From = 'change => !string.Equals(change.By?.Sid, SystemSid, StringComparison.Ordinal)' - To = 'change => string.Equals(change.By?.Sid, SystemSid, StringComparison.Ordinal)' + From = 'change => change.By is { } by && !string.Equals(by.Sid, SystemSid, StringComparison.Ordinal)' + To = 'change => change.By is { } by && string.Equals(by.Sid, SystemSid, StringComparison.Ordinal)' Test = 'HistoryTests.The_second_group_counts_each_choice_and_not_by_system_says_how_many_of_how_many' } + @{ + # Logic report H03, 2026-10-27: a change with no account recorded may have been SYSTEM's. + Name = 'window history: not by SYSTEM leaves out a change with no account' + File = 'src\Bws.Gui\ViewModels\HistoryFilter.cs' + From = 'change => change.By is { } by && !string.Equals' + To = 'change => change.By is not { } by || !string.Equals' + Test = 'HistoryClaimsTests.A_change_with_no_account_recorded_is_not_counted_as_not_by_system' + } @{ # GUI rule 3: a refresh keeps the tick somebody set. Name = 'window history: a new reading keeps the tick of the same name' @@ -13962,16 +13971,16 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : # A spreadsheet sums numbers, and a blank is not one. Name = 'window history export: times is a number on every line' File = 'src\Bws.Gui\ViewModels\HistoryExporting.cs' - From = ' [row.When, row.Count.ToString(CultureInfo.InvariantCulture), row.Since, row.Entry, row.Change, row.By, row.Group.Latest.By?.Sid ?? string.Empty],' - To = ' [row.When, row.Times, row.Since, row.Entry, row.Change, row.By, row.Group.Latest.By?.Sid ?? string.Empty],' + From = ' row.Count.ToString(CultureInfo.InvariantCulture),' + To = ' row.Times,' Test = 'HistoryExportingTests.A_line_carries_its_count_as_a_number_and_the_account_by_sid' } @{ # AS-c and rule 3: the account in the file is its SID beside its name. Name = 'window history export: the account goes in by sid' File = 'src\Bws.Gui\ViewModels\HistoryExporting.cs' - From = ' [row.When, row.Count.ToString(CultureInfo.InvariantCulture), row.Since, row.Entry, row.Change, row.By, row.Group.Latest.By?.Sid ?? string.Empty],' - To = ' [row.When, row.Count.ToString(CultureInfo.InvariantCulture), row.Since, row.Entry, row.Change, row.By, string.Empty],' + From = ' row.Group.Latest.By?.Sid ?? string.Empty' + To = ' string.Empty' Test = 'HistoryExportingTests.A_line_carries_its_count_as_a_number_and_the_account_by_sid' } @{ @@ -14592,6 +14601,137 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'if (turn <= _turn)' Test = 'SnapshottingTests.A_form_opened_afresh_while_a_snapshot_was_read_is_left_open_when_it_is_written' } + + # ---- Logic report, package L3 (window, queries, history), 2026-10-27 - docs/ANALIZA-RAPORTU-LOGIKI-20261026.md ---- + # H03 is above, beside the entry it re-anchored. + @{ + # H04: the history table sorts by the moment, not by the local text. + Name = 'window history: the table opens sorted by the moment' + File = 'src\Bws.Gui\HistoryTable.xaml.cs' + From = 'FirstSort.Keep(Lines, nameof(HistoryRow.At), ListSortDirection.Descending);' + To = 'FirstSort.Keep(Lines, nameof(HistoryRow.When), ListSortDirection.Descending);' + Test = 'HistoryWindowGuards.The_history_opens_newest_first_with_the_mark_on_when_and_again_on_the_next_opening' + } + @{ + # H04: a line of a group sorts by its LAST change, the time it shows. + Name = 'window history: a line sorts by the last change it stands for' + File = 'src\Bws.Gui\ViewModels\HistoryRow.cs' + From = 'At = group.Last ?? group.Latest.At;' + To = 'At = group.First ?? group.Latest.At;' + Test = 'HistoryWindowGuards.Pressing_a_heading_sorts_the_history_and_pressing_it_again_turns_the_order_round' + } + @{ + # H04: the file names one moment, offset included. + Name = 'window history export: times carry their offset' + File = 'src\Bws.Gui\ViewModels\HistoryWords.cs' + From = '"yyyy-MM-dd HH:mm:ss zzz"' + To = '"yyyy-MM-dd HH:mm:ss"' + Test = 'HistoryExportingTests.A_line_carries_its_count_as_a_number_and_the_account_by_sid' + } + @{ + # C03: a catalogue not found, or no way to ask, is not signed as before. + Name = 'signature: the SHA-1 rescue refuses only when a catalogue was found' + File = 'src\Bws.Core\WindowsBinaryInspector.Catalogue.cs' + From = 'listed && answer is { } found' + To = 'answer is { } found' + Test = 'CatalogueRescueTests.A_machine_that_will_not_ask_with_sha1_is_not_signed_as_before' + } + @{ + # H02: only a circular log overwrites. + Name = 'history: an archived log at its limit is not overwriting' + File = 'src\Bws.Core\History\ChangeHistory.cs' + From = 'Overwritten = facts.Mode == LogMode.Circular && facts.AtSizeLimit,' + To = 'Overwritten = !facts.Full && facts.AtSizeLimit,' + Test = 'ChangeHistoryTests.An_archived_log_at_its_limit_is_not_called_overwritten' + } + @{ + # H02: the window says where an archived log keeps its past. + Name = 'window history: an archived log says older events may be in archive files' + File = 'src\Bws.Gui\ViewModels\HistoryWords.cs' + From = 'if (history.Log.Mode == LogMode.AutoBackup)' + To = 'if (history.Log.Mode == LogMode.Retain)' + Test = 'HistoryClaimsTests.No_log_is_promised_to_have_lost_nothing_and_an_archived_one_says_where_its_past_is' + } + @{ + # H01: the window's own plan reads the history again. + Name = 'window history: a plan reads the history again' + File = 'src\Bws.Gui\ViewModels\MainViewModel.cs' + From = ' History.AfterOwnChange();' + To = ' // the history is left as it was' + Test = 'HistoryAfterPlanTests.The_windows_own_plan_reads_the_history_again_once_the_log_has_had_time_to_hold_it' + } + @{ + # H01: and waits for the log to hold the change first - measured, about a second. + Name = 'window history: the reading after a plan waits for the log' + File = 'src\Bws.Gui\ViewModels\HistoryReading.AfterPlan.cs' + From = '_clock.Wait(LogWrites);' + To = '_clock.Wait(TimeSpan.Zero);' + Test = 'HistoryAfterPlanTests.The_windows_own_plan_reads_the_history_again_once_the_log_has_had_time_to_hold_it' + } + @{ + # H01: a reading out when the plan ended began too early, so another follows. + Name = 'window history: a reading begun before the plan is followed by another' + File = 'src\Bws.Gui\ViewModels\HistoryReading.AfterPlan.cs' + From = ' while (_owed);' + To = ' while (_owed && !_asked);' + Test = 'HistoryAfterPlanTests.A_reading_out_when_the_plan_ends_is_followed_by_one_that_holds_the_change' + } + @{ + # R07: another process's memory is not read yet. + Name = 'window row: memory does not pass to another process' + File = 'src\Bws.Gui\ViewModels\EntryRow.cs' + From = 'memory.Outcome == ReadOutcome.NotRead ? memory' + To = 'memory.Outcome != ReadOutcome.NotRead ? memory' + Test = 'RowMemoryTests.Another_process_has_memory_nobody_has_read' + } + @{ + # R07: no process, no memory - what MemoryPass says. + Name = 'window row: a stopped entry has no process to use memory' + File = 'src\Bws.Gui\ViewModels\EntryRow.cs' + From = ': process.IsPresent ? Reading.NotRead()' + To = ': !process.IsPresent ? Reading.NotRead()' + Test = 'RowMemoryTests.A_stopped_entry_has_no_process_to_use_memory' + } + @{ + # R07: a claim on one process's memory is no claim on the next one's. + Name = 'window panel: a claim on memory belongs to its process' + File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' + From = '? claimed & ~ExtraRead.Memory' + To = '? claimed' + Test = 'PanelReadingTests.Memory_goes_with_its_process_and_the_panel_reads_the_next_ones' + } + @{ + # R07: with memory held for every row, a moved row is read at once. + Name = 'window list: a row moved to another process has its memory read again' + File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' + From = 'if (!_have.HasFlag(ExtraRead.Memory) || _reader is null)' + To = 'if (_have.HasFlag(ExtraRead.Memory) || _reader is null)' + Test = 'PanelReadingTests.A_list_holding_every_rows_memory_reads_a_restarted_one_at_once' + } + @{ + # R10: a spent budget tries nothing more. + Name = 'query budget: a spent walk tries no more costly matches' + File = 'src\Bws.Core\Querying\QueryBudget.cs' + From = 'if (_spent >= _whole)' + To = 'if (_spent > _whole)' + Test = 'QueryBudgetTests.Once_the_walk_has_spent_its_budget_a_costly_pattern_is_not_tried_and_says_it_ran_out' + } + @{ + # R10: a costly pattern pays out of the walk. + Name = 'query budget: the backtracking engine is charged' + File = 'src\Bws.Core\Querying\QueryValues.cs' + From = 'return _costly ? budget.Match(pattern!, value) : Verdict.Of(pattern!.IsMatch(value));' + To = 'return _costly ? Verdict.Of(pattern!.IsMatch(value)) : budget.Match(pattern!, value);' + Test = 'QueryBudgetTests.Once_the_walk_has_spent_its_budget_a_costly_pattern_is_not_tried_and_says_it_ran_out' + } + @{ + # R10: the terminal's filter is one walk with one budget. + Name = 'query budget: a listing filtered at once shares one budget' + File = 'src\Bws.Core\Querying\Query.cs' + From = ' var pass = Pass();' + To = ' var pass = new QueryPass(this, new QueryBudget(() => TimeSpan.Zero, QueryBudget.Whole));' + Test = 'QueryBudgetTests.A_listing_filtered_at_once_shares_one_budget_rather_than_one_per_entry' + } ) $mutations From a05241ca5ea5f5a3873e1c46b98384303eed7d35 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 27 Oct 2026 18:28:08 +0100 Subject: [PATCH 2/3] Answer the review of the window, history and catalogue fixes Rows the tick moves to another process have their memory read in one pass over the listing, rather than one pass per row: a host process restarting moves every service it holds at once. The pass also reads a row the details panel is reading, so a memory query is narrowed with that row's new figure rather than before the panel's answer comes back. A failed reading in the details panel remembers which families it was for, and goes once all of them have been read again. Memory is read anew when the process moves, and the old failure no longer stands beside the new figure. The panel's reading no longer clears its own claim on memory when the process restarts mid-reading: the claim remembers its process since the earlier fix, and the mutation registry showed the hand-clearing could go without any test noticing. The SHA-1 catalogue rescue runs through a seam that hands it both questions' answers, so a test sees a refusal from a catalogue found under SHA-1 carried through, not only the rule at the end. Two tests that could not fail now can: one walk's spent query budget is not carried into the next, and the history after a plan is read only once the log has had time to hold the change. Co-Authored-By: Claude Opus 5.5 --- .../WindowsBinaryInspector.Catalogue.cs | 63 ++++---- src/Bws.Gui/ViewModels/EntryRow.cs | 5 +- src/Bws.Gui/ViewModels/Readings.OneEntry.cs | 142 ++++++++++++------ tests/Bws.Architecture.Tests/ShapeCeilings.cs | 6 +- tests/Bws.Core.Tests/CatalogueRescueTests.cs | 50 +++++- tests/Bws.Core.Tests/QueryBudgetTests.cs | 23 ++- tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs | 12 +- tests/Bws.Gui.Tests/HistoryLog.cs | 10 +- tests/Bws.Gui.Tests/MovedMemoryTests.cs | 137 +++++++++++++++++ tests/mutation/entries.ps1 | 58 ++++++- 10 files changed, 412 insertions(+), 94 deletions(-) create mode 100644 tests/Bws.Gui.Tests/MovedMemoryTests.cs diff --git a/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs b/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs index 5449c60d..61a40dba 100644 --- a/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs +++ b/src/Bws.Core/WindowsBinaryInspector.Catalogue.cs @@ -29,9 +29,25 @@ public sealed partial class WindowsBinaryInspector /// that catalogue. The hash doubles as the member tag, spelled out in hex, which is how /// the verification finds the right entry inside the catalogue. /// - private Reading ThroughCatalogue(string file, SafeFileHandle handle, HANDLE raw) + private Reading ThroughCatalogue(string file, SafeFileHandle handle, HANDLE raw) => + Rescued(algorithm => ThroughCatalogue(file, handle, raw, algorithm)); + + /// + /// One catalogue question's answer - null when no catalogue lists the file under that algorithm - and + /// whether a catalogue listing it was found, which is what decides the SHA-1 rescue (C03). + /// + internal readonly record struct CatalogueAnswer(Reading? Signature, bool Listed); + + /// + /// The two questions in their order, given a way to ask one. Its own method since the review of PR + /// #96, so a test can hand it answers no machine here gives - a catalogue found under SHA-1 that then + /// refuses - and see them carried through, not only the rule at the end. + /// + internal static Reading Rescued(Func ask) { - if (ThroughCatalogue(file, handle, raw, "SHA256", out _) is { } bySha256) + ArgumentNullException.ThrowIfNull(ask); + + if (ask("SHA256").Signature is { } bySha256) { return bySha256; } @@ -52,9 +68,9 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha // that lists the file and then cannot be read or verified is not "nobody signed this" - the // file is somebody's member, and a refusal with Windows' number is what is known. Until that day // it came back as NotSigned too, a confident answer about a file that may well be signed. - var bySha1 = ThroughCatalogue(file, handle, raw, "SHA1", out var listed); + var bySha1 = ask("SHA1"); - return AfterSha1(bySha1, listed); + return AfterSha1(bySha1.Signature, bySha1.Listed); } /// @@ -68,21 +84,16 @@ internal static Reading AfterSha1(Reading? ans : Reading.Present(new BinarySignature(SignatureStatus.NotSigned, NoSignature, Publisher: null)); /// - /// One catalogue question asked with one hash algorithm. Null when no catalogue lists the file - /// under it, which is not yet an answer - see the method above. + /// One catalogue question asked with one hash algorithm. No signature when no catalogue lists the + /// file under it, which is not yet an answer - see . Listed for every answer from + /// the verification against a catalogue found, refusals included, and not for a refusal that came + /// before anybody looked (C03). /// /// The member hash comes from the same handle as everything else, and measured 2026-10-06 it /// does not depend on where the handle's file pointer stands (one-handle.ps1, P6). /// - /// - /// Whether a catalogue listing the file was found - true for every answer from the verification - /// against it, refusals included, and false for a refusal that came before anybody looked (C03). - /// - private unsafe Reading? ThroughCatalogue( - string file, SafeFileHandle handle, HANDLE raw, string algorithm, out bool listed) + private unsafe CatalogueAnswer ThroughCatalogue(string file, SafeFileHandle handle, HANDLE raw, string algorithm) { - listed = false; - // One context per file, and a context held per worker instead was measured on 2026-09-29 // (tools/signature-probe/auto-cache.ps1, variant "held"). Acquiring and releasing is almost // free - what a fresh context costs is its FIRST catalogue lookup, about 1 ms per file on @@ -91,9 +102,7 @@ internal static Reading AfterSha1(Reading? ans // smaller than that pass's own spread. Not worth a context whose lifetime spans calls. if (!PInvoke.CryptCATAdminAcquireContext2(out var admin, null, algorithm, null)) { - var error = Marshal.GetLastWin32Error(); - - return Reading.Denied(error, ManagerTerms.Describe(error)); + return Unlisted(Marshal.GetLastWin32Error()); } try @@ -110,7 +119,7 @@ internal static Reading AfterSha1(Reading? ans if (!probed && probeError != (int)WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER) { - return Reading.Denied(probeError, ManagerTerms.Describe(probeError)); + return Unlisted(probeError); } if (size == 0) @@ -118,18 +127,14 @@ internal static Reading AfterSha1(Reading? ans // Asked for no room and did not fail. A file always hashes to something, so this // is not a shape the system produces - refused with a definite code rather than // with a stale one, because there is nothing true to say about it. - return Reading.Denied( - (int)WIN32_ERROR.ERROR_INVALID_DATA, - ManagerTerms.Describe((int)WIN32_ERROR.ERROR_INVALID_DATA)); + return Unlisted((int)WIN32_ERROR.ERROR_INVALID_DATA); } var hash = new byte[size]; if (!PInvoke.CryptCATAdminCalcHashFromFileHandle2(admin, handle, ref size, hash)) { - var error = Marshal.GetLastWin32Error(); - - return Reading.Denied(error, ManagerTerms.Describe(error)); + return Unlisted(Marshal.GetLastWin32Error()); } var catalogue = PInvoke.CryptCATAdminEnumCatalogFromHash(admin, hash); @@ -138,14 +143,12 @@ internal static Reading AfterSha1(Reading? ans { // No catalogue lists this file under this algorithm, and it carries nothing of its // own. "Nobody signed this" is the honest answer only once both have been asked. - return null; + return new CatalogueAnswer(Signature: null, Listed: false); } - listed = true; - try { - return VerifyAgainst(catalogue, admin, file, raw, hash); + return new CatalogueAnswer(VerifyAgainst(catalogue, admin, file, raw, hash), Listed: true); } finally { @@ -158,6 +161,10 @@ internal static Reading AfterSha1(Reading? ans } } + /// Refused before any catalogue was looked for - the context, or the hash. + private static CatalogueAnswer Unlisted(int error) => + new(Reading.Denied(error, ManagerTerms.Describe(error)), Listed: false); + private unsafe Reading VerifyAgainst( nint catalogue, nint admin, string file, HANDLE raw, byte[] hash) { diff --git a/src/Bws.Gui/ViewModels/EntryRow.cs b/src/Bws.Gui/ViewModels/EntryRow.cs index 9a62785b..c4c176f8 100644 --- a/src/Bws.Gui/ViewModels/EntryRow.cs +++ b/src/Bws.Gui/ViewModels/EntryRow.cs @@ -356,9 +356,10 @@ internal bool Absorb(ScmEntry entry, DateTimeOffset now) /// /// Absent and present-with-a-value are different answers even when neither carries a /// number, and comparing only the numbers would miss a service stopping if the manager - /// happened to have refused the reading before. + /// happened to have refused the reading before. Internal since the review of PR #96, so the + /// panel's readings ask the same question rather than spelling it out twice. /// - private static bool SameProcess(Reading left, Reading right) => + internal static bool SameProcess(Reading left, Reading right) => left.Outcome == right.Outcome && left.Value == right.Value; /// diff --git a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs index bd714849..adb653c0 100644 --- a/src/Bws.Gui/ViewModels/Readings.OneEntry.cs +++ b/src/Bws.Gui/ViewModels/Readings.OneEntry.cs @@ -62,15 +62,38 @@ internal sealed partial class Readings : IEntryReads /// said nothing at all - the claim stood, so nothing was read, and the lines said "not read" as /// though nobody had tried. Kept here, a failure is said for the entry it happened to, every time /// that entry is shown, until a new listing lets the panel try again. + /// + /// With the families it was for, since the review of PR #96. A claim on memory no longer + /// stands for the whole listing (R07), so memory is read again once the process moves - and a failure + /// that stood until the next listing went on saying the reading failed beside a figure read since. It + /// goes once every family it was for has been read again (). /// - private readonly Dictionary _failures = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _failures = new(StringComparer.OrdinalIgnoreCase); /// public string FailureOf(EntryRow row) { ArgumentNullException.ThrowIfNull(row); - return _failures.GetValueOrDefault(row.ServiceName, string.Empty); + return _failures.TryGetValue(row.ServiceName, out var failed) ? failed.Why : string.Empty; + } + + /// A reading of these families failed for this row - added to whatever it already failed at. + private void FailedAt(EntryRow row, ExtraRead families, Exception failure) => + _failures[row.ServiceName] = ( + families | _failures.GetValueOrDefault(row.ServiceName).Families, + string.Join(" ", Causes.Of(failure))); + + /// + /// These families were read for this row: a failure they cover in full is over. One that also covered a + /// family nobody has read since stays said - that family's line still says "not read", and why. + /// + private void Answered(EntryRow row, ExtraRead families) + { + if (_failures.TryGetValue(row.ServiceName, out var failed) && (failed.Families & ~families) == ExtraRead.None) + { + _failures.Remove(row.ServiceName); + } } /// @@ -99,33 +122,52 @@ public ExtraRead Claim(EntryRow row) /// /// The list holds every row's memory, and the tick has just moved rows to another process - read /// theirs now rather than leaving "not read" in a column somebody turned on until F5 (R07, 2026-10-27). - /// The panel's own path, one row at a time: a tick moves a handful of rows at most, and a pass over - /// the listing costs 5-8 ms (MemoryPass). A row whose memory is already being read is not asked twice. + /// + /// ONE PASS FOR EVERY MOVED ROW, since the review of PR #96. The first version took the panel's + /// path, one row at a time, and each of those is a whole over the listing - so + /// a host process restarting, which moves every service it holds in one tick, cost one pass per service. + /// The pass counts sharing over the whole listing whatever it is asked, so one answers them all. + /// + /// A row the panel is reading as well is read here too, the other half of that review. An open + /// panel claims a moved row the moment it moves, and skipping claimed rows let the list be narrowed + /// before the panel's answer came - which lands through AbsorbOne and narrows nothing, so a question + /// about memory left that row out until something else moved. Asked twice, it costs one process more. /// /// Inside the tick, awaited, the way the second phase runs inside a reading - so the list is - /// narrowed once, with the new figures, and no read is started and walked away from. + /// narrowed once, with the new figures, and no read is started and walked away from. The tick holds the + /// reading guard, so nothing moves a process or replaces the listing while this is out. /// - private Task MemoryAgainForMovedAsync() + private async Task MemoryAgainForMovedAsync() { - if (!_have.HasFlag(ExtraRead.Memory) || _reader is null) + if (!_have.HasFlag(ExtraRead.Memory) || _reader is not { } reader) + { + return; + } + + var moved = _index.Ordered.Where(row => row.Entry.Memory.Outcome == ReadOutcome.NotRead).ToList(); + + if (moved.Count == 0) { - return Task.CompletedTask; + return; } - var reads = new List(); + var everything = _index.Everything; + var counted = await Attempt(() => MemoryPass.Fill(everything, reader)).ConfigureAwait(true); - foreach (var row in _index.Ordered) + if (counted.Failure is { } failure) { - var claimed = Claimed(row); + moved.ForEach(row => FailedAt(row, ExtraRead.Memory, failure)); - if (row.Entry.Memory.Outcome == ReadOutcome.NotRead && !claimed.HasFlag(ExtraRead.Memory)) - { - Stake(row, claimed | ExtraRead.Memory); - reads.Add(ReadAsync(row, ExtraRead.Memory)); - } + return; } - return Task.WhenAll(reads); + var byName = counted.Answer!.ToDictionary(entry => entry.ServiceName, StringComparer.OrdinalIgnoreCase); + + foreach (var row in moved.Where(row => byName.ContainsKey(row.ServiceName))) + { + _index.AbsorbOne(row.Entry with { Memory = byName[row.ServiceName].Memory }); + Answered(row, ExtraRead.Memory); + } } /// What has been claimed for this row - less a claim on memory made for another process (R07). @@ -135,7 +177,7 @@ private ExtraRead Claimed(EntryRow row) return claimed.HasFlag(ExtraRead.Memory) && _memoryFor.TryGetValue(row.ServiceName, out var process) - && !(process.Outcome == row.Entry.ProcessId.Outcome && process.Value == row.Entry.ProcessId.Value) + && !EntryRow.SameProcess(process, row.Entry.ProcessId) ? claimed & ~ExtraRead.Memory : claimed; } @@ -163,29 +205,21 @@ public async Task ReadAsync(EntryRow row, ExtraRead families) // - MemoryPass says so where it is written. The index is fresher than the last full reading, // because the tick writes the process identifiers back into it. var everything = _index.Everything; + var attempt = await Attempt(() => One(before, everything, families)).ConfigureAwait(true); - ScmEntry read; - - try - { - read = await Task.Run(() => One(before, everything, families)).ConfigureAwait(true); - } -#pragma warning disable CA1031 - // Broad, for the reason the second phase gives at its own catch: every file answers for - // itself, so what arrives here is the reading failing as a whole. It must neither take the - // window down nor leave the panel implying that nobody tried - so it becomes a sentence - // about THIS entry, said by the panel whenever the entry is shown. Not for a listing that - // has gone, whose entries nobody will be shown again. - catch (Exception failure) + // A failure is a sentence about THIS entry - not for a listing that has gone, whose entries nobody + // will be shown again. + if (attempt.Failure is { } failure) { if (!_gone && listing == _listing) { - _failures[row.ServiceName] = string.Join(" ", Causes.Of(failure)); + FailedAt(row, families, failure); } return; } -#pragma warning restore CA1031 + + var read = attempt.Answer!; // ANSWERED FOR ENTRIES THAT NO LONGER EXIST, or for a window nobody is looking at: dropped. // The panel asks again for the fresh entry, because the new listing cleared what it claimed. @@ -195,20 +229,42 @@ public async Task ReadAsync(EntryRow row, ExtraRead families) } var now = row.Entry; - var sameProcess = now.ProcessId.Outcome == before.ProcessId.Outcome && now.ProcessId.Value == before.ProcessId.Value; + var sameProcess = EntryRow.SameProcess(now.ProcessId, before.ProcessId); // A RESTART BETWEEN THE QUESTION AND THE ANSWER, since the review of PR #16: the memory read - // belongs to the old process and is dropped below, and the claim goes with it so the panel - // asks again for the new one. A restart arrives through the tick, which never clears a - // claim - without this the line said "not read" until the next full reading, and Enter did - // nothing. It cannot spin: each round is a real reading, and it stops the moment the process - // holds still for the few milliseconds one takes. - if (families.HasFlag(ExtraRead.Memory) && !sameProcess && _claimed.TryGetValue(row.ServiceName, out var had)) + // belongs to the old process and is dropped (Carry), and the panel asks again for the new one - + // without that the line said "not read" until the next full reading, and Enter did nothing. The + // claim used to be cleared here by hand. Since R07 it no longer needs to be: a claim on memory + // remembers its process, and Claimed drops it once the process moves, which is what a restart + // mid-reading is too. The mutation registry found the hand-clearing could be removed with no + // test noticing (review of PR #96), so it was. It cannot spin: each round is a real reading, + // and it stops the moment the process holds still for the few milliseconds one takes. + _index.AbsorbOne(Carry(now, read, families, sameProcess)); + Answered(row, sameProcess ? families : families & ~ExtraRead.Memory); + } + + /// + /// Work for the pool, with what it threw handed back rather than thrown - the one broad catch both readings + /// in this file share, since the review of PR #96 gave the tick a reading of its own beside the panel's. + /// + /// Broad, for the reason the second phase gives at its own catch: every file and every process answers + /// for itself, so what arrives here is a reading failing as a whole. It must neither take the window down + /// nor leave the panel implying that nobody tried - so each caller turns it into a sentence about the + /// entries it was for, said by the panel whenever one of them is shown. + /// + private static async Task<(T? Answer, Exception? Failure)> Attempt(Func work) + where T : class + { + try { - _claimed[row.ServiceName] = had & ~ExtraRead.Memory; + return (await Task.Run(work).ConfigureAwait(true), null); } - - _index.AbsorbOne(Carry(now, read, families, sameProcess)); +#pragma warning disable CA1031 + catch (Exception failure) + { + return (null, failure); + } +#pragma warning restore CA1031 } /// Called wherever the index takes a whole list - see . diff --git a/tests/Bws.Architecture.Tests/ShapeCeilings.cs b/tests/Bws.Architecture.Tests/ShapeCeilings.cs index ed769c1a..0f7928af 100644 --- a/tests/Bws.Architecture.Tests/ShapeCeilings.cs +++ b/tests/Bws.Architecture.Tests/ShapeCeilings.cs @@ -40,11 +40,13 @@ internal static class ShapeCeilings internal const int LongestMethod = 68; /// - /// Twelve at 48 lines of code or more. Fourteen until 2026-10-05, when BinaryPathResolver.Resolve + /// Eleven at 48 lines of code or more. Fourteen until 2026-10-05, when BinaryPathResolver.Resolve /// handed its prefix walk to a method of its own (stability report R-3) and left the crowd, and /// thirteen until later that day, when Execution.Report did the same with its timing lines (C-4). + /// Twelve until the review of PR #96, when the catalogue question in WindowsBinaryInspector answered + /// with one value instead of an answer and a flag beside it, and went from 50 lines to 43. /// - internal const int MethodsNearLongest = 12; + internal const int MethodsNearLongest = 11; /// /// 23 forks, EntryDocument.From. Catalogue.Make is next at 22 - measured 2026-10-05, when this diff --git a/tests/Bws.Core.Tests/CatalogueRescueTests.cs b/tests/Bws.Core.Tests/CatalogueRescueTests.cs index ee0bea2f..c43eaeec 100644 --- a/tests/Bws.Core.Tests/CatalogueRescueTests.cs +++ b/tests/Bws.Core.Tests/CatalogueRescueTests.cs @@ -6,13 +6,61 @@ namespace Bws.Core.Tests; /// No catalogue, or no way to ask, is "not signed" as before. /// /// The rule without Windows, because no machine here has a catalogue that fails once found - the -/// owner's has no file only an SHA-1 catalogue names at all (R-6, 2026-10-05). +/// owner's has no file only an SHA-1 catalogue names at all (R-6, 2026-10-05). And since the review of PR +/// #96 the questions too, through : the rule held on its own +/// would stay green if the answer to the SHA-1 question stopped reaching it. /// public sealed class CatalogueRescueTests { private const int TrustFailure = unchecked((int)0x80096010); private const int NoSignature = unchecked((int)0x800B0100); + private static readonly WindowsBinaryInspector.CatalogueAnswer NotListed = new(Signature: null, Listed: false); + + [Fact] + public void A_file_only_an_sha1_catalogue_lists_that_then_refuses_is_a_refusal_with_its_number() + { + var asked = new List(); + + var answer = WindowsBinaryInspector.Rescued(algorithm => + { + asked.Add(algorithm); + + return algorithm == "SHA1" + ? new(Reading.Denied(TrustFailure, "The digital signature of the object did not verify."), Listed: true) + : NotListed; + }); + + Assert.Equal(["SHA256", "SHA1"], asked); + Assert.Equal(ReadOutcome.Denied, answer.Outcome); + Assert.Equal(TrustFailure, answer.ErrorCode); + } + + [Fact] + public void A_file_an_sha256_catalogue_lists_is_never_asked_about_again() + { + var signed = Reading.Present(new BinarySignature(SignatureStatus.Trusted, 0, Publisher: "Microsoft Windows")); + var asked = new List(); + + var answer = WindowsBinaryInspector.Rescued(algorithm => + { + asked.Add(algorithm); + + return new(signed, Listed: true); + }); + + Assert.Equal(["SHA256"], asked); + Assert.Equal(signed, answer); + } + + [Fact] + public void A_file_no_catalogue_lists_under_either_is_not_signed() + { + var answer = WindowsBinaryInspector.Rescued(_ => NotListed); + + Assert.Equal(SignatureStatus.NotSigned, answer.Value!.Status); + } + [Fact] public void A_catalogue_found_and_not_verified_is_a_refusal_with_its_number() { diff --git a/tests/Bws.Core.Tests/QueryBudgetTests.cs b/tests/Bws.Core.Tests/QueryBudgetTests.cs index a2481ed8..d5754615 100644 --- a/tests/Bws.Core.Tests/QueryBudgetTests.cs +++ b/tests/Bws.Core.Tests/QueryBudgetTests.cs @@ -8,8 +8,9 @@ namespace Bws.Core.Tests; /// owner's decision of 2026-10-26 "a budget for the whole query". Before it each entry had fifty milliseconds /// of its own: 37.5 s over 804 entries from the terminal, and the window still for as long. /// -/// A ruler handed in, not the clock, for every test but the last: what is held is which matches are -/// tried and which are not, and a stopwatch would also be measuring the machine (QueryPatterns says why). +/// A ruler handed in, not the clock, for every test but two: what is held is which matches are tried +/// and which are not, and a stopwatch would also be measuring the machine (QueryPatterns says why). The two +/// are about the budget Query hands out itself, which only the clock spends. /// public sealed class QueryBudgetTests { @@ -49,11 +50,23 @@ public void The_linear_engine_is_never_charged_however_little_is_left() public void Every_walk_begins_with_the_whole_budget() { // The window walks its rows again at every keystroke - a spent budget is not carried into the next. - var query = QueryParserTests.Valid("name:/Spool(?=er)/"); + // SPENT FOR REAL FIRST, since the review of PR #96: the first version spent microseconds, and would + // have passed with one budget shared by every walk. Each runaway match takes its whole fifty + // milliseconds, so four reach the two hundred and six leave room for a limit that fires a little + // early - the matches after the budget is gone are not tried, so the six cost about a quarter second. + var runOfAs = new string('a', 32); + var runaway = Entries.Named(runOfAs, runOfAs); + var quick = Entries.Named("aab", "aab"); + var query = QueryParserTests.Valid(@"name:/(a+)+\1b/"); + var spent = query.Pass(); - query.Pass().Match(Entries.Named("Spooler", "Spooler")); + for (var match = 0; match < 6; match++) + { + spent.Match(runaway); + } - Assert.True(query.Pass().Match(Entries.Named("Spooler", "Spooler")).Matched); + Assert.True(spent.Match(quick).TooCostly, "The first walk never spent its budget, so the rest proves nothing."); + Assert.True(query.Pass().Match(quick).Matched); } [Fact] diff --git a/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs b/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs index 100caa62..b9bc9795 100644 --- a/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs +++ b/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs @@ -19,16 +19,20 @@ public async Task The_windows_own_plan_reads_the_history_again_once_the_log_has_ await model.OpenHistory(); - var before = clock.Elapsed; + // THE LOG HOLDS THE CHANGE ONLY ONCE THE TIME HAS PASSED, since the review of PR #96. The first + // version put it there before the plan, so a reading that read first and waited after would have + // found it all the same. + var landsAt = clock.Elapsed + HistoryReading.LogWrites; - log.History = new ChangeHistory( - HistoryLog.Log(), [.. HistoryLog.Ordinary().Changes.Prepend(HistoryLog.ByPerson("Spooler", HistoryLog.Day(21, 9), back: true))]); + log.Later = ( + new ChangeHistory( + HistoryLog.Log(), [.. HistoryLog.Ordinary().Changes.Prepend(HistoryLog.ByPerson("Spooler", HistoryLog.Day(21, 9), back: true))]), + () => clock.Elapsed >= landsAt); await model.LoadKeepingAsync(); await model.History.Out; Assert.Equal(2, log.Reads); - Assert.True(clock.Elapsed - before >= HistoryReading.LogWrites, "the reading did not wait for the log"); Assert.Equal(8, model.History.Shown!.Filter.Rows.Sum(row => row.Count)); } diff --git a/tests/Bws.Gui.Tests/HistoryLog.cs b/tests/Bws.Gui.Tests/HistoryLog.cs index 8c2c9322..75328c83 100644 --- a/tests/Bws.Gui.Tests/HistoryLog.cs +++ b/tests/Bws.Gui.Tests/HistoryLog.cs @@ -23,6 +23,12 @@ internal sealed class HistoryLog : IChangeHistoryReader /// Thrown by the next reading instead of answering, when set. internal Exception? FailWith { get; set; } + /// + /// What the log holds once the test says it has landed - the way the real log shows a change about a second + /// after it is made (H01). Until then a reading answers with . + /// + internal (ChangeHistory History, Func Landed)? Later { get; set; } + /// How many readings were asked for. internal int Reads => Volatile.Read(ref _reads); @@ -31,7 +37,9 @@ public ChangeHistory Read() Interlocked.Increment(ref _reads); _gate.Wait(TimeSpan.FromSeconds(30)); - return FailWith is { } failure ? throw failure : History; + return FailWith is { } failure ? throw failure + : Later is { } later && later.Landed() ? later.History + : History; } internal void Hold() => _gate.Reset(); diff --git a/tests/Bws.Gui.Tests/MovedMemoryTests.cs b/tests/Bws.Gui.Tests/MovedMemoryTests.cs new file mode 100644 index 00000000..e9422e88 --- /dev/null +++ b/tests/Bws.Gui.Tests/MovedMemoryTests.cs @@ -0,0 +1,137 @@ +using Bws.Core; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// Memory read again for rows the tick moved to another process - logic report R07, and the review of PR #96, +/// which found three faults in how: one pass over the listing per moved row, a row the panel was reading left +/// out of the list the tick narrowed, and a failure that went on being said beside a figure read since. +/// +public sealed class MovedMemoryTests +{ + [Fact] + public async Task A_host_restarting_its_services_is_read_in_one_pass() + { + // Three services in one host, the way svchost holds them - it restarts, and all three move in one tick. + var memory = new Memory(); + var machine = new LiveMachine(Rows.Entry("BITS"), Rows.Entry("Dnscache"), Rows.Entry("Spooler")); + var model = new MainViewModel(machine, new SteppedClock(), reader: memory); + await model.LoadAsync(); + model.QueryText = "memory:>1MB"; + await model.RefreshAsync(); + var asked = memory.Asked; + + machine.Start("BITS", 5001); + machine.Start("Dnscache", 5001); + machine.Start("Spooler", 5001); + await model.RefreshAsync(); + + // One question for the one process behind them. A pass per moved row asked it three times. + Assert.Equal(1, memory.Asked - asked); + Assert.Equal(3, model.Rows.Count); + Assert.All(model.Rows, row => Assert.Equal(new ProcessMemory(Memory.Of(5001), 2048, 3), row.Entry.Memory.Value)); + } + + [Fact] + public async Task A_row_the_panel_is_reading_is_in_the_list_the_tick_narrows() + { + // The panel claims the moved row the moment it moves. The tick reads it as well, so the list is + // narrowed with its figure in hand rather than before the panel's answer comes back. + var memory = new Memory(); + var machine = new LiveMachine(Rows.Entry("Spooler")); + var model = new MainViewModel(machine, new SteppedClock(), reader: memory); + await model.LoadAsync(); + model.QueryText = "memory:>1MB"; + await model.RefreshAsync(); + var row = model.Rows.Single(); + await Open(model, row); + + memory.HoldForTwo(); + machine.Start("Spooler", 4321); + await model.RefreshAsync().WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + memory.Release(); + await model.Chosen.CatchingUp.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Equal(Memory.Of(4321), row.Entry.Memory.Value!.WorkingSet); + Assert.Contains(row, model.Rows); + } + + [Fact] + public async Task A_failure_to_read_memory_goes_once_the_next_process_is_read() + { + var memory = new Memory { FailsFor = 4321 }; + var machine = new LiveMachine(Rows.Entry("Spooler")); + var model = new MainViewModel(machine, new SteppedClock(), reader: memory); + await model.LoadAsync(); + var row = model.Rows.Single(); + await Open(model, row); + + machine.Start("Spooler", 4321); + await Moved(model); + + Assert.Contains("The process went away mid-read", model.Chosen.Notice, StringComparison.Ordinal); + + machine.Start("Spooler", 5555); + await Moved(model); + + Assert.Equal(Memory.Of(5555), row.Entry.Memory.Value!.WorkingSet); + Assert.Equal(string.Empty, model.Chosen.Notice); + } + + /// The tick, and the panel's own reading it set off - with a ceiling, for the reason PanelReadingTests.Open gives. + private static async Task Moved(MainViewModel model) + { + await model.RefreshAsync(); + await model.Chosen.CatchingUp.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + + private static async Task Open(MainViewModel model, EntryRow row) + { + model.Chosen.Row = row; + Assert.True(model.Chosen.Show()); + + await model.Chosen.CatchingUp.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + + /// + /// A memory reader whose figure says which process it was read for, that fails for one process, and that + /// can hold its questions until two are waiting - the panel's and the tick's - so neither can finish first. + /// + private sealed class Memory : IProcessMemoryReader + { + private readonly ManualResetEventSlim _gate = new(initialState: true); + private int _asked; + private int _waiting; + + internal static long Of(int processId) => processId * 1024L; + + internal int Asked => Volatile.Read(ref _asked); + + internal int FailsFor { get; init; } + + internal void HoldForTwo() + { + Volatile.Write(ref _waiting, 0); + _gate.Reset(); + } + + internal void Release() => _gate.Set(); + + public Reading Read(int processId) + { + Interlocked.Increment(ref _asked); + + if (Interlocked.Increment(ref _waiting) >= 2) + { + _gate.Set(); + } + + _gate.Wait(TimeSpan.FromSeconds(10)); + + return processId == FailsFor + ? throw new InvalidOperationException("The process went away mid-read.") + : Reading.Present(new ProcessMemory(Of(processId), 2048, 1)); + } + } +} diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1 index d20bb8d6..30366882 100644 --- a/tests/mutation/entries.ps1 +++ b/tests/mutation/entries.ps1 @@ -1220,19 +1220,21 @@ $mutations = @( }, @{ # Review of PR #16: a restart arrives through the tick, which never clears a claim, so the - # dropped memory stayed "not read" until the next full reading and Enter did nothing. + # dropped memory stayed "not read" until the next full reading and Enter did nothing. Since the + # review of PR #96 on the claim remembering its process (R07) - the hand-clearing in ReadAsync + # it used to break was found redundant by this very entry, and removed. Name = 'details: a restart lets the panel ask for the memory again' File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' - From = ' _claimed[row.ServiceName] = had & ~ExtraRead.Memory;' - To = ' _claimed[row.ServiceName] = had;' + From = '? claimed & ~ExtraRead.Memory' + To = '? claimed' Test = 'PanelReadingTests.A_memory_read_for_a_process_that_has_since_restarted_is_read_again_for_the_new_one' }, @{ # Review of PR #16, case one: the failure followed the panel to the next entry. Name = 'details: a failure is said for the entry it happened to' File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' - From = 'return _failures.GetValueOrDefault(row.ServiceName, string.Empty);' - To = 'return _failures.Values.FirstOrDefault() ?? string.Empty;' + From = 'return _failures.TryGetValue(row.ServiceName, out var failed) ? failed.Why : string.Empty;' + To = 'return _failures.Values.Select(failed => failed.Why).FirstOrDefault() ?? string.Empty;' Test = 'PanelReadingTests.A_failure_is_said_for_the_entry_that_failed_and_not_for_the_next' }, @{ @@ -1255,7 +1257,7 @@ $mutations = @( # The failure swallowed: lines saying "not read" as though nobody had tried. Rule 8. Name = 'details: a failed panel reading is said' File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' - From = ' _failures[row.ServiceName] = string.Join(" ", Causes.Of(failure));' + From = ' FailedAt(row, families, failure);' To = ' _ = failure;' Test = 'PanelReadingTests.A_reading_that_fails_is_said_in_the_panel' }, @@ -14636,6 +14638,14 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = 'answer is { } found' Test = 'CatalogueRescueTests.A_machine_that_will_not_ask_with_sha1_is_not_signed_as_before' } + @{ + # C03, review of PR #96: what the SHA-1 question found reaches the rule, not only the rule itself. + Name = 'signature: the SHA-1 question tells the rescue whether a catalogue was found' + File = 'src\Bws.Core\WindowsBinaryInspector.Catalogue.cs' + From = 'return AfterSha1(bySha1.Signature, bySha1.Listed);' + To = 'return AfterSha1(bySha1.Signature, listed: false);' + Test = 'CatalogueRescueTests.A_file_only_an_sha1_catalogue_lists_that_then_refuses_is_a_refusal_with_its_number' + } @{ # H02: only a circular log overwrites. Name = 'history: an archived log at its limit is not overwriting' @@ -14668,6 +14678,14 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = '_clock.Wait(TimeSpan.Zero);' Test = 'HistoryAfterPlanTests.The_windows_own_plan_reads_the_history_again_once_the_log_has_had_time_to_hold_it' } + @{ + # H01, review of PR #96: the wait comes before the reading, not after it. + Name = 'window history: the reading after a plan reads only once it has waited' + File = 'src\Bws.Gui\ViewModels\HistoryReading.AfterPlan.cs' + From = ' _clock.Wait(LogWrites);' + To = ' var early = reader.Read(); _clock.Wait(LogWrites); return early;' + Test = 'HistoryAfterPlanTests.The_windows_own_plan_reads_the_history_again_once_the_log_has_had_time_to_hold_it' + } @{ # H01: a reading out when the plan ended began too early, so another follows. Name = 'window history: a reading begun before the plan is followed by another' @@ -14704,10 +14722,26 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : # R07: with memory held for every row, a moved row is read at once. Name = 'window list: a row moved to another process has its memory read again' File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' - From = 'if (!_have.HasFlag(ExtraRead.Memory) || _reader is null)' - To = 'if (_have.HasFlag(ExtraRead.Memory) || _reader is null)' + From = 'if (!_have.HasFlag(ExtraRead.Memory) || _reader is not { } reader)' + To = 'if (_have.HasFlag(ExtraRead.Memory) || _reader is not { } reader)' Test = 'PanelReadingTests.A_list_holding_every_rows_memory_reads_a_restarted_one_at_once' } + @{ + # R07, review of PR #96: a row the panel is reading is read by the tick too, so it is narrowed in. + Name = 'window list: a moved row the panel is reading is in the list the tick narrows' + File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' + From = '.Where(row => row.Entry.Memory.Outcome == ReadOutcome.NotRead).ToList();' + To = '.Where(row => row.Entry.Memory.Outcome == ReadOutcome.NotRead && !Claimed(row).HasFlag(ExtraRead.Memory)).ToList();' + Test = 'MovedMemoryTests.A_row_the_panel_is_reading_is_in_the_list_the_tick_narrows' + } + @{ + # R07, review of PR #96: a failure goes once what it was about has been read since. + Name = 'window panel: a failure goes once its families are read again' + File = 'src\Bws.Gui\ViewModels\Readings.OneEntry.cs' + From = '(failed.Families & ~families) == ExtraRead.None' + To = '(failed.Families & families) == ExtraRead.None' + Test = 'MovedMemoryTests.A_failure_to_read_memory_goes_once_the_next_process_is_read' + } @{ # R10: a spent budget tries nothing more. Name = 'query budget: a spent walk tries no more costly matches' @@ -14732,6 +14766,14 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) : To = ' var pass = new QueryPass(this, new QueryBudget(() => TimeSpan.Zero, QueryBudget.Whole));' Test = 'QueryBudgetTests.A_listing_filtered_at_once_shares_one_budget_rather_than_one_per_entry' } + @{ + # R10, review of PR #96: what one walk spent is not carried into the next. + Name = 'query budget: every walk begins with the whole budget' + File = 'src\Bws.Core\Querying\QueryBudget.cs' + From = ' private TimeSpan _spent;' + To = ' private static TimeSpan _spent;' + Test = 'QueryBudgetTests.Every_walk_begins_with_the_whole_budget' + } ) $mutations From 0d195effd96f9befb2094c689d944e24566ec854 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 27 Oct 2026 18:40:02 +0100 Subject: [PATCH 3/3] Hold the panel's reading in the test of memory following its process The details panel stays open on the row and asks for the new process's memory the moment the row moves, so "not read" lasts only until that reading returns. A fast runner had already read past it when the test looked, and the test went red in CI while it passed locally. The second reading is now held while the moment between is checked, then released and the new process's figure checked. Co-Authored-By: Claude Opus 5.5 --- tests/Bws.Gui.Tests/PanelReadingTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Bws.Gui.Tests/PanelReadingTests.cs b/tests/Bws.Gui.Tests/PanelReadingTests.cs index d13d9ba9..7c4eee08 100644 --- a/tests/Bws.Gui.Tests/PanelReadingTests.cs +++ b/tests/Bws.Gui.Tests/PanelReadingTests.cs @@ -180,11 +180,15 @@ public async Task A_memory_read_for_a_process_that_has_since_restarted_is_read_a /// The figure belongs to the process it was read for - logic report R07, 2026-10-27. The tick moved /// the status and the process and left the megabytes, so a stopped service went on "using" what its /// old process used, and the panel did not ask again because its claim still stood. + /// + /// The panel's second reading is held, since it went red in CI on PR #96 and green here: the + /// panel stays open on the row and asks for the new process the moment the row moves, so "not read" + /// is a moment, and a fast enough runner had already read past it when the test looked. /// [Fact] public async Task Memory_goes_with_its_process_and_the_panel_reads_the_next_ones() { - var memory = new Memory(); + var memory = new Memory { HoldTheSecond = true }; var machine = new LiveMachine(Entry()); var model = new MainViewModel(machine, new SteppedClock(), new Inspector(), memory); await model.LoadAsync(); @@ -203,7 +207,8 @@ public async Task Memory_goes_with_its_process_and_the_panel_reads_the_next_ones Assert.Equal(ReadOutcome.NotRead, row.Entry.Memory.Outcome); - await Open(model, row); + memory.Release(); + await model.Chosen.CatchingUp.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); Assert.Equal(Memory.Of(4321), row.Entry.Memory.Value!.WorkingSet); Assert.Equal(2, memory.Asked);