using Bws.Core; using Bws.Core.Querying; namespace Bws.Gui.ViewModels; /// /// The three expensive families for ONE entry, read when the details panel opens on it - UX-GUI-005. /// /// WHY THE PANEL READS AT ALL, and the number decided it. Until 2026-09-24 the panel showed /// "not read" beside the signature, the memory and who depends on an entry, and sent the reader to /// turn on a column - which reads every entry on the machine, about 12 s of processor for the /// signatures over 797 (2026-09-28). The owner's rule was that the panel reads for itself if the cost allows, and the cost was /// measured before anything was built (tools/details-probe, 797 entries, five counted runs, each in /// a fresh process): the three families for one typical service 46.7-66.9 ms the first time a /// process asks, the signature family 6-13 ms every time after, and 425-518 ms for the one 98 MB /// driver on the machine. The command line's `show` verb already read this way, over one entry. /// /// BESIDE THE READING GUARD, NOT INSIDE IT, and that is the difference from the second phase. /// That phase runs inside the reading and stops the tick for its seconds - the price it names at /// its own head. This one is tens of milliseconds and must not stop the list, so it runs beside /// the guard and is kept honest by two things instead: an answer is written only if the index is /// still holding the listing it was read against, and it is written into the one row it is about /// through - never through the whole-list absorb, which removes /// every row it is not handed. /// /// INTO THE ROW RATHER THAN ONLY INTO THE PANEL, because the panel and a copy must say the /// same thing about the same entry - Details.AsText carries that rule. And never into /// _have: one filled row does not answer a question about the whole list, so a query about /// signatures still asks for the second phase exactly as before. /// internal sealed partial class Readings : IEntryReads { /// /// Which whole listing the rows hold. Raised every time the index takes a whole list - a full /// reading AND the second phase - because either replaces the entries the panel read against. /// /// Both, and the second is the one that is easy to miss. The second phase absorbs the /// listing with only the families somebody asked for, so with just the memory column on it /// hands every row an entry without a signature - including the one the panel had just filled. /// Counting only full readings would leave that signature marked as already asked for, and the /// panel on "not read" until the next opening. /// private int _listing; /// 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. /// /// Beside the claims and cleared with them, since the review of PR #16. The failure was /// the panel's until then, and a reading belongs to one ENTRY: opening another entry while a /// reading was out dressed the new one in the old one's failure, and opening the failed one again /// 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. /// private readonly Dictionary _failures = new(StringComparer.OrdinalIgnoreCase); /// public string FailureOf(EntryRow row) { ArgumentNullException.ThrowIfNull(row); return _failures.GetValueOrDefault(row.ServiceName, string.Empty); } /// public ExtraRead Claim(EntryRow row) { ArgumentNullException.ThrowIfNull(row); var entry = row.Entry; var claimed = Claimed(row); var missing = (entry.Signature.Outcome == ReadOutcome.NotRead ? ExtraRead.Signatures : ExtraRead.None) | (entry.Memory.Outcome == ReadOutcome.NotRead ? ExtraRead.Memory : ExtraRead.None) | (entry.RequiredBy.Outcome == ReadOutcome.NotRead ? ExtraRead.RequiredBy : ExtraRead.None); var owed = missing & Available & ~claimed; if (owed != ExtraRead.None) { Stake(row, claimed | owed); } return owed; } /// /// The list holds every row's memory, and the tick has just moved rows to another process - read /// theirs now rather than leaving "not read" in a column somebody turned on until F5 (R07, 2026-10-27). /// The panel's own path, one row at a time: a tick moves a handful of rows at most, and a pass over /// the listing costs 5-8 ms (MemoryPass). A row whose memory is already being read is not asked twice. /// /// Inside the tick, awaited, the way the second phase runs inside a reading - so the list is /// narrowed once, with the new figures, and no read is started and walked away from. /// private Task MemoryAgainForMovedAsync() { if (!_have.HasFlag(ExtraRead.Memory) || _reader is null) { return Task.CompletedTask; } var reads = new List(); foreach (var row in _index.Ordered) { var claimed = Claimed(row); if (row.Entry.Memory.Outcome == ReadOutcome.NotRead && !claimed.HasFlag(ExtraRead.Memory)) { Stake(row, claimed | ExtraRead.Memory); reads.Add(ReadAsync(row, ExtraRead.Memory)); } } return Task.WhenAll(reads); } /// What has been claimed for this row - less a claim on memory made for another process (R07). private ExtraRead Claimed(EntryRow row) { var claimed = _claimed.GetValueOrDefault(row.ServiceName); return claimed.HasFlag(ExtraRead.Memory) && _memoryFor.TryGetValue(row.ServiceName, out var process) && !(process.Outcome == row.Entry.ProcessId.Outcome && process.Value == row.Entry.ProcessId.Value) ? claimed & ~ExtraRead.Memory : claimed; } private void Stake(EntryRow row, ExtraRead claimed) { _claimed[row.ServiceName] = claimed; if (claimed.HasFlag(ExtraRead.Memory)) { _memoryFor[row.ServiceName] = row.Entry.ProcessId; } } /// public async Task ReadAsync(EntryRow row, ExtraRead families) { ArgumentNullException.ThrowIfNull(row); var listing = _listing; var before = row.Entry; // THE WHOLE LISTING FOR THE MEMORY, taken here on the window's thread: the pass counts how // many entries share each process, so over one entry it would call a shared svchost private // - 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; 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) { if (!_gone && listing == _listing) { _failures[row.ServiceName] = string.Join(" ", Causes.Of(failure)); } return; } #pragma warning restore CA1031 // 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. if (_gone || listing != _listing) { return; } var now = row.Entry; var sameProcess = now.ProcessId.Outcome == before.ProcessId.Outcome && now.ProcessId.Value == before.ProcessId.Value; // 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)) { _claimed[row.ServiceName] = had & ~ExtraRead.Memory; } _index.AbsorbOne(Carry(now, read, families, sameProcess)); } /// Called wherever the index takes a whole list - see . private void TookAWholeList() { _listing++; _claimed.Clear(); _memoryFor.Clear(); _failures.Clear(); } /// /// The three families for one entry, through the product's own passes and in the second phase's /// order - files, then processes, then the manager. The same calls the command line's `show` /// verb makes, and what tools/details-probe measured. /// private ScmEntry One(ScmEntry entry, IReadOnlyList everything, ExtraRead families) { var one = entry; if (families.HasFlag(ExtraRead.Signatures)) { one = SecondPass.Fill([one], _inspector!)[0]; } if (families.HasFlag(ExtraRead.Memory)) { // Not in the listing any more - removed between the question and the answer - leaves the // memory unread, which is true, rather than taking a figure from somewhere else. var counted = MemoryPass.Fill(everything, _reader!) .FirstOrDefault(candidate => string.Equals(candidate.ServiceName, one.ServiceName, StringComparison.OrdinalIgnoreCase)); one = counted is null ? one : one with { Memory = counted.Memory }; } if (families.HasFlag(ExtraRead.RequiredBy)) { one = RequiredByPass.Fill([one], _catalog)[0]; } return one; } /// /// What was read, onto the entry the row holds NOW - which the tick may have moved since, so its /// status and process are kept and does /// not light the row as changed. /// /// The memory only if the process is still the one that was asked. A service restarted /// between the question and the answer has a new process, and the old one's figure against it /// would be a stranger's memory in the right field. /// private static ScmEntry Carry(ScmEntry now, ScmEntry read, ExtraRead families, bool sameProcess) { var carried = now; if (families.HasFlag(ExtraRead.Signatures)) { carried = carried with { Signature = read.Signature, FileVersion = read.FileVersion, BinaryHash = read.BinaryHash }; } if (families.HasFlag(ExtraRead.Memory) && sameProcess) { carried = carried with { Memory = read.Memory }; } if (families.HasFlag(ExtraRead.RequiredBy)) { carried = carried with { RequiredBy = read.RequiredBy }; } return carried; } }