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..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") is { } bySha256) + ArgumentNullException.ThrowIfNull(ask); + + if (ask("SHA256").Signature is { } bySha256) { return bySha256; } @@ -45,23 +61,38 @@ 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 = ask("SHA1"); + + return AfterSha1(bySha1.Signature, bySha1.Listed); } /// - /// 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. + /// 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. 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). /// - private unsafe Reading? ThroughCatalogue( - string file, SafeFileHandle handle, HANDLE raw, string algorithm) + private unsafe CatalogueAnswer ThroughCatalogue(string file, SafeFileHandle handle, HANDLE raw, string algorithm) { // 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 @@ -71,9 +102,7 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha // 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 @@ -90,7 +119,7 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha if (!probed && probeError != (int)WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER) { - return Reading.Denied(probeError, ManagerTerms.Describe(probeError)); + return Unlisted(probeError); } if (size == 0) @@ -98,18 +127,14 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha // 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); @@ -118,12 +143,12 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha { // 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); } try { - return VerifyAgainst(catalogue, admin, file, raw, hash); + return new CatalogueAnswer(VerifyAgainst(catalogue, admin, file, raw, hash), Listed: true); } finally { @@ -136,6 +161,10 @@ private Reading ThroughCatalogue(string file, SafeFileHandle ha } } + /// 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/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..c4c176f8 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); @@ -351,11 +356,28 @@ 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; + /// + /// 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..adb653c0 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. /// @@ -54,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); + } } /// @@ -71,7 +102,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 +113,85 @@ 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). + /// + /// 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. The tick holds the + /// reading guard, so nothing moves a process or replaces the listing while this is out. + /// + private async Task MemoryAgainForMovedAsync() + { + 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; + } + + var everything = _index.Everything; + var counted = await Attempt(() => MemoryPass.Fill(everything, reader)).ConfigureAwait(true); + + if (counted.Failure is { } failure) + { + moved.ForEach(row => FailedAt(row, ExtraRead.Memory, failure)); + + return; + } + + 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). + private ExtraRead Claimed(EntryRow row) + { + var claimed = _claimed.GetValueOrDefault(row.ServiceName); + + return claimed.HasFlag(ExtraRead.Memory) + && _memoryFor.TryGetValue(row.ServiceName, out var process) + && !EntryRow.SameProcess(process, row.Entry.ProcessId) + ? 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) { @@ -101,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. @@ -133,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 . @@ -154,6 +272,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/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.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..c43eaeec --- /dev/null +++ b/tests/Bws.Core.Tests/CatalogueRescueTests.cs @@ -0,0 +1,104 @@ +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). 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() + { + 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..d5754615 --- /dev/null +++ b/tests/Bws.Core.Tests/QueryBudgetTests.cs @@ -0,0 +1,110 @@ +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 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 +{ + 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. + // 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(); + + for (var match = 0; match < 6; match++) + { + spent.Match(runaway); + } + + 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] + 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..b9bc9795 --- /dev/null +++ b/tests/Bws.Gui.Tests/HistoryAfterPlanTests.cs @@ -0,0 +1,74 @@ +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(); + + // 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.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.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..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(); @@ -59,7 +67,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/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/Bws.Gui.Tests/PanelReadingTests.cs b/tests/Bws.Gui.Tests/PanelReadingTests.cs index 9a1b570d..7c4eee08 100644 --- a/tests/Bws.Gui.Tests/PanelReadingTests.cs +++ b/tests/Bws.Gui.Tests/PanelReadingTests.cs @@ -176,6 +176,69 @@ 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. + /// + /// 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 { HoldTheSecond = true }; + 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); + + 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); + } + + /// + /// 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..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' }, @@ -10011,8 +10013,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 +13084,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 +13126,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 +13652,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 +13973,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 +14603,177 @@ 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' + } + @{ + # 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' + 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, 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' + 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 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' + 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' + } + @{ + # 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