diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a87d8..6f0b0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ is not part of this repository. - Reading who depends on each entry - the Required by column, `required:` in a query and `bws list --required-by` - asks about several entries at once and takes a fraction of the time it did. +- After a plan is carried out, after something is installed or removed, and when a column or a + query needs something the window has not read yet, the window no longer verifies every + signature again. It keeps what it already knows about files whose path has not changed and + verifies only new or changed ones. F5 still verifies everything again, so a file replaced by + someone else in the meantime shows its new verdict after the next F5. ## [0.3.0] - 2026-09-25 diff --git a/src/Bws.Core/IBinaryInspector.cs b/src/Bws.Core/IBinaryInspector.cs index c9f7b6c..266a66a 100644 --- a/src/Bws.Core/IBinaryInspector.cs +++ b/src/Bws.Core/IBinaryInspector.cs @@ -43,4 +43,22 @@ public interface IBinaryInspector /// Absent when the path names nothing on disk, denied when it is there and unreadable. /// Reading ReadHash(string file); + + /// + /// The inspector ONE pass of the second phase asks through - backlog 468, 2026-09-29. + /// + /// Whatever an inspector remembers between files lives as long as one pass and no longer. + /// The Windows one keeps the publisher of every catalogue it has opened, and the window holds + /// one inspector for its whole life - so a catalogue replaced under the same name went on + /// showing its old publisher until the window closed, F5 or no F5. Since the same day F5 is + /// promised to verify everything again (`ADR-13`, the owner's S-1 decision), which a memory + /// older than the F5 would quietly break. + /// + /// This rather than the caller clearing the memory, because a pass is several threads + /// asking at once and the details panel can ask about one entry while a pass is out. A fresh + /// inspector per pass shares nothing with the one before it, so there is no moment at which a + /// clear could land in the middle of somebody else's question. An inspector that remembers + /// nothing answers with itself. + /// + IBinaryInspector ForOnePass() => this; } diff --git a/src/Bws.Core/SecondPass.cs b/src/Bws.Core/SecondPass.cs index 2d9d90d..29af7c2 100644 --- a/src/Bws.Core/SecondPass.cs +++ b/src/Bws.Core/SecondPass.cs @@ -85,8 +85,10 @@ public static IReadOnlyList Fill( ArgumentNullException.ThrowIfNull(inspector); ArgumentOutOfRangeException.ThrowIfLessThan(degreeOfParallelism, 1); + // THROUGH AN INSPECTOR THAT REMEMBERS NOTHING FROM THE PASS BEFORE, since 2026-09-29 - + // backlog 468. Why it is the pass that asks for one is written on IBinaryInspector.ForOnePass. var files = Distinct(entries); - var answers = Ask(files, inspector, degreeOfParallelism); + var answers = Ask(files, inspector.ForOnePass(), degreeOfParallelism); var filled = new List(entries.Count); foreach (var entry in entries) @@ -117,7 +119,12 @@ private static List Distinct(IReadOnlyList entries) foreach (var entry in entries) { - if (entry.BinaryFile.Outcome == ReadOutcome.Present && seen.Add(entry.BinaryFile.Value!)) + // An entry already holding an answer - kept from an earlier pass by Keep - is not asked + // about again. Every entry a first reading hands over holds none, so for everybody who + // does not call Keep this line changes nothing. + if (entry.BinaryFile.Outcome == ReadOutcome.Present + && !Answered(entry) + && seen.Add(entry.BinaryFile.Value!)) { files.Add(entry.BinaryFile.Value!); } @@ -165,6 +172,60 @@ private static Dictionary Ask( private readonly record struct Answer( Reading Signature, Reading Version, Reading Hash); + /// + /// A fresh listing, with the answers an earlier reading already had about the SAME FILES. + /// + /// The owner's decision S-1 of 2026-09-28, recorded in `ADR-13`, and it exists for the + /// window alone. Carrying out a plan writes no file and no path - only a start type and the + /// delayed flag, with the path handed over as "unchanged" - so verifying every signature again + /// afterwards cannot give a new answer about anything the plan did, and it cost 4.3-5.0 s of + /// clock on two processors (P3 of the performance analysis). So after a plan, after a change to + /// what is installed and when a question needs a family the window has not read, the window + /// keeps what it knows. F5 keeps nothing and verifies everything again. + /// + /// Keyed by the FILE, never by the entry: a new service pointing at a svchost that was + /// already verified has an answer, and an entry whose path changed has none - a different path + /// is a different key, so it is verified afresh. "Not read" is not an answer and is not + /// kept: a file on another machine that was skipped is asked about again. + /// + /// The price, accepted by the owner before the decision: a file replaced by somebody else + /// between F5 and a plan keeps its old verdict until the next F5. + /// + public static IReadOnlyList Keep(IReadOnlyList fresh, IEnumerable earlier) + { + ArgumentNullException.ThrowIfNull(fresh); + ArgumentNullException.ThrowIfNull(earlier); + + var known = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in earlier) + { + if (entry.BinaryFile.Outcome == ReadOutcome.Present && Answered(entry)) + { + known.TryAdd(entry.BinaryFile.Value!, new Answer(entry.Signature, entry.FileVersion, entry.BinaryHash)); + } + } + + if (known.Count == 0) + { + return fresh; + } + + var kept = new List(fresh.Count); + + foreach (var entry in fresh) + { + kept.Add(entry.BinaryFile.Outcome == ReadOutcome.Present && known.TryGetValue(entry.BinaryFile.Value!, out var answer) + ? entry with { Signature = answer.Signature, FileVersion = answer.Version, BinaryHash = answer.Hash } + : entry); + } + + return kept; + } + + /// Whether a pass - this one or an earlier one kept by - already answered for this entry. + private static bool Answered(ScmEntry entry) => entry.Signature.Outcome != ReadOutcome.NotRead; + private static ScmEntry Fill(ScmEntry entry, Dictionary answers) { switch (entry.BinaryFile.Outcome) @@ -192,6 +253,9 @@ private static ScmEntry Fill(ScmEntry entry, Dictionary answers) BinaryHash = Reading.Denied(entry.BinaryFile.ErrorCode, entry.BinaryFile.Reason!) }; + case ReadOutcome.Present when Answered(entry): + return entry; + case ReadOutcome.Present: var answer = answers[entry.BinaryFile.Value!]; diff --git a/src/Bws.Core/WindowsBinaryInspector.cs b/src/Bws.Core/WindowsBinaryInspector.cs index 0861529..7528401 100644 --- a/src/Bws.Core/WindowsBinaryInspector.cs +++ b/src/Bws.Core/WindowsBinaryInspector.cs @@ -69,9 +69,22 @@ public sealed partial class WindowsBinaryInspector(NetworkPaths networkPaths = N /// Concurrent because the interface will read this from background threads once there /// is an interface. Cheap insurance against a bug that would only ever appear under /// load, in a thread nobody is watching. + /// + /// ONE PASS LONG SINCE 2026-09-29, and until then as long as the window - backlog 468. + /// The window holds one inspector for its whole life, so a catalogue replaced under the same + /// name showed its old publisher until it closed. Every pass now asks through + /// , which starts this empty. Whether Windows ever replaces a catalogue + /// under the same name is NOT CHECKED - what made it matter is that F5 is promised to verify + /// everything again, and a memory older than the F5 would break that promise quietly. /// private readonly ConcurrentDictionary _publishers = new(StringComparer.OrdinalIgnoreCase); + /// + /// A fresh inspector with nothing remembered, for one pass of the second phase - see + /// . It costs one empty dictionary. + /// + public IBinaryInspector ForOnePass() => new WindowsBinaryInspector(networkPaths); + /// /// Whether this file is one nobody asked us to reach for. /// diff --git a/src/Bws.Gui/MainWindow.Carrying.cs b/src/Bws.Gui/MainWindow.Carrying.cs index 75cc75d..843971f 100644 --- a/src/Bws.Gui/MainWindow.Carrying.cs +++ b/src/Bws.Gui/MainWindow.Carrying.cs @@ -149,8 +149,9 @@ internal async Task CarryOut() _model.Planned.Finished(await running.ConfigureAwait(true)); // Whatever moved, moved. Asking now rather than waiting up to a second means the list - // agrees with the panel by the time somebody looks up from it. - await _model.LoadAsync().ConfigureAwait(true); + // agrees with the panel by the time somebody looks up from it. KEEPING what is known + // about files since 2026-09-29 - the plan wrote none, `ADR-13`. + await _model.LoadKeepingAsync().ConfigureAwait(true); return true; } diff --git a/src/Bws.Gui/ViewModels/MainViewModel.cs b/src/Bws.Gui/ViewModels/MainViewModel.cs index c2f5606..c142026 100644 --- a/src/Bws.Gui/ViewModels/MainViewModel.cs +++ b/src/Bws.Gui/ViewModels/MainViewModel.cs @@ -317,9 +317,20 @@ public bool Interacting /// Handed straight on, because the window binds to this class and the state machine behind it /// is not something a window should have to know the name of. /// - public async Task LoadAsync() + public Task LoadAsync() => ReadMachineAsync(Relisting.Afresh); + + /// + /// Reads the machine in full after a plan, keeping what the window already knows about files. + /// + /// 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. + /// + public Task LoadKeepingAsync() => ReadMachineAsync(Relisting.Keeping); + + private async Task ReadMachineAsync(Relisting how) { - await _readings.LoadAsync().ConfigureAwait(true); + await _readings.LoadAsync(how).ConfigureAwait(true); // THE MACHINE OVERVIEW IS COUNTED FROM THE LISTING, AND THE LISTING ARRIVES AFTER THE WINDOW // DOES. Without this line every number on that screen is zero and stays zero: it is worked diff --git a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs index b45c499..06846c2 100644 --- a/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs +++ b/src/Bws.Gui/ViewModels/Readings.SecondPhase.cs @@ -230,9 +230,10 @@ private IReadOnlyList Fill(IReadOnlyList entries, ExtraRead /// Answered by reading the machine again rather than by filling in what is held. The /// entries kept from the last full reading are older than the rows on screen - a tick has been /// writing statuses into them since - so absorbing them would roll those changes back. Reading - /// again costs about a tenth of a second on sixteen processors on top of a pass that costs - /// seconds, and it is the - /// difference between a fresh answer and a stale one. + /// again costs about a tenth of a second on sixteen processors, and it is the difference between + /// a fresh answer and a stale one. Since 2026-09-29 that reading keeps what the window knows + /// about files (, the owner's word), so turning a column on + /// no longer verifies every signature again - only F5 does. /// internal bool WantsMore() => Asked(); diff --git a/src/Bws.Gui/ViewModels/Readings.cs b/src/Bws.Gui/ViewModels/Readings.cs index 1fb0348..95303e4 100644 --- a/src/Bws.Gui/ViewModels/Readings.cs +++ b/src/Bws.Gui/ViewModels/Readings.cs @@ -180,7 +180,10 @@ internal Readings( /// comes back to the interface thread, which is precisely why the hole was invisible: it /// is a question of ordering rather than of two threads touching one field. /// - internal async Task LoadAsync() + /// + /// Afresh for the first look and F5, keeping file answers after a plan - . + /// + internal async Task LoadAsync(Relisting how) { if (_reading) { @@ -191,7 +194,7 @@ internal async Task LoadAsync() try { - await LoadEverything().ConfigureAwait(true); + await LoadEverything(how).ConfigureAwait(true); } finally { @@ -203,7 +206,7 @@ internal async Task LoadAsync() /// The reading itself, without the guard, because the tick already holds it when it finds /// out that it needs a full one. /// - private async Task LoadEverything() + private async Task LoadEverything(Relisting how) { Says.Status = Texts.Of("gui.status.reading"); @@ -238,9 +241,20 @@ private async Task LoadEverything() Says.Incomplete = false; _failed = false; + // KEPT RATHER THAN VERIFIED AGAIN, SINCE 2026-09-29 - the owner's S-1 decision in `ADR-13`. + // Taken from the rows BEFORE they are replaced, because the rows are where every answer this + // window has ever had lives, the details panel's single-entry ones included. The price the + // owner accepted: a file replaced by somebody else keeps its old verdict until the next F5. + if (how == Relisting.Keeping) + { + entries = SecondPass.Keep(entries, _index.Everything); + } + // The families belong to THESE entries, not to the window, so a fresh listing starts with - // none of them. Anything else would show a signature read against a file that has since - // been replaced, which for an audit tool is worse than showing nothing. + // none of them HELD. Anything else would show a signature read against a file that has + // since been replaced, which for an audit tool is worse than showing nothing. Answers kept + // just above are in the entries themselves - whether the family counts as held for the whole + // list is still decided by the pass below, which asks only about what nobody answered. _have = ExtraRead.None; _tried = ExtraRead.None; @@ -295,7 +309,9 @@ internal async Task RefreshAsync() // thing to leave standing than a status that is one second old. if (WantsMore()) { - await LoadEverything().ConfigureAwait(true); + // Keeping, on the owner's word of 2026-09-29: turning a column on is not F5, and the + // signatures already on screen were verified since the last one. + await LoadEverything(Relisting.Keeping).ConfigureAwait(true); return; } @@ -332,8 +348,9 @@ internal async Task RefreshAsync() // The unguarded one, because the guard above is already held. Calling the // public entry point here would find its own flag raised and quietly do // nothing, which is the sort of deadlock-by-politeness that looks like the - // machine simply never installing anything. - await LoadEverything().ConfigureAwait(true); + // machine simply never installing anything. Keeping file answers: what was + // installed or removed brings its own path, and that one is verified. + await LoadEverything(Relisting.Keeping).ConfigureAwait(true); break; case Freshening.Moved: diff --git a/src/Bws.Gui/ViewModels/Relisting.cs b/src/Bws.Gui/ViewModels/Relisting.cs new file mode 100644 index 0000000..e8a88d4 --- /dev/null +++ b/src/Bws.Gui/ViewModels/Relisting.cs @@ -0,0 +1,26 @@ +namespace Bws.Gui.ViewModels; + +/// +/// How a full reading of the machine treats what the window already knows about files. +/// +/// Two ways since 2026-09-29, the owner's S-1 decision recorded in `ADR-13`. Until then every +/// full reading verified every signature again - about 12 s of processor over 797 entries, 4.3-5.0 s +/// of clock on two processors - including the one after a plan, which writes no file and no path +/// and so cannot have changed a single answer. The words for the two are in docs/03. +/// +internal enum Relisting +{ + /// + /// Everything read and verified again: the first look and F5. The only way a verdict older than + /// the reading goes away, and the reason F5 exists. + /// + Afresh, + + /// + /// The machine read again, and the signature, version and hash of every file the window has + /// already asked about kept rather than verified again: after a plan, after a change to what is + /// installed, and when a question needs a family the window has not read. A file with a new or + /// changed path is still verified. See SecondPass.Keep. + /// + Keeping +} diff --git a/tests/Bws.Core.Tests/SecondPassKeepTests.cs b/tests/Bws.Core.Tests/SecondPassKeepTests.cs new file mode 100644 index 0000000..b3ff903 --- /dev/null +++ b/tests/Bws.Core.Tests/SecondPassKeepTests.cs @@ -0,0 +1,128 @@ +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// File answers kept from an earlier reading, and an inspector that remembers nothing between passes. +/// +/// Written 2026-09-29, W4 of the performance series. Two changes that belong together because +/// both are about how OLD an answer may be. carries answers forward +/// on purpose - the owner's S-1 decision in `ADR-13` - and +/// makes sure nothing else does: the publisher memory of the Windows inspector used to outlive every +/// F5 the window was ever given (backlog 468). +/// +public sealed class SecondPassKeepTests +{ + private const string Spooler = @"C:\Windows\System32\spoolsv.exe"; + private const string Host = @"C:\Windows\System32\svchost.exe"; + + [Fact] + public void A_file_already_answered_is_kept_and_not_asked_about_again() + { + var earlier = SecondPass.Fill(Listing(), new FakeBinaryInspector()); + var inspector = new FakeBinaryInspector(); + + var filled = SecondPass.Fill(SecondPass.Keep(Listing(), earlier), inspector); + + Assert.Empty(inspector.SignaturesAsked); + Assert.Equal(earlier.Select(entry => entry.Signature.Value), filled.Select(entry => entry.Signature.Value)); + Assert.Equal(earlier.Select(entry => entry.BinaryHash.Value), filled.Select(entry => entry.BinaryHash.Value)); + } + + [Fact] + public void A_new_path_and_a_changed_path_are_verified_and_nothing_else_is() + { + // Keyed by the file, so the new service on an already verified svchost has its answer, and + // the spooler pointing somewhere else is a different key and is asked about. + const string Moved = @"D:\Spool\spoolsv.exe"; + const string New = @"C:\Program Files\Vendor\agent.exe"; + + var earlier = SecondPass.Fill(Listing(), new FakeBinaryInspector()); + + var fresh = new[] + { + At("Spooler", Moved), + At("Dhcp", Host), + At("RpcSs", Host), + At("VendorAgent", New) + }; + + var inspector = new FakeBinaryInspector(); + var filled = SecondPass.Fill(SecondPass.Keep(fresh, earlier), inspector); + + Assert.Equal([New, Moved], inspector.SignaturesAsked.Order(StringComparer.Ordinal)); + Assert.All(filled, entry => Assert.Equal(ReadOutcome.Present, entry.Signature.Outcome)); + } + + [Fact] + public void A_file_nobody_read_is_asked_about_again() + { + // "Not read" is what a skipped file on another machine answers, and it is not an answer. + var skipping = new FakeBinaryInspector(new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [Spooler] = Reading.NotRead() + }); + + var earlier = SecondPass.Fill(Listing(), skipping); + var inspector = new FakeBinaryInspector(); + + SecondPass.Fill(SecondPass.Keep(Listing(), earlier), inspector); + + Assert.Equal([Spooler], inspector.SignaturesAsked); + } + + [Fact] + public void Every_pass_asks_through_an_inspector_that_remembers_nothing_from_the_last() + { + // The window's own inspector refuses to be asked anything, so the pass only works if it + // asks through the one ForOnePass hands it. + var windowLong = new OnlyThroughAPass(); + + var filled = SecondPass.Fill(Listing(), windowLong); + + Assert.Equal(1, windowLong.Passes); + Assert.All(filled, entry => Assert.Equal(ReadOutcome.Present, entry.Signature.Outcome)); + } + + [Fact] + public void The_windows_inspector_starts_every_pass_empty() + { + // The only thing a fresh instance can promise without a real catalogue behind it: it IS a + // fresh instance, so the publisher memory of the one before it does not come along. + var inspector = new WindowsBinaryInspector(); + + Assert.NotSame(inspector, inspector.ForOnePass()); + } + + private static ScmEntry[] Listing() => + [ + At("Spooler", Spooler), + At("Dhcp", Host), + At("RpcSs", Host) + ]; + + private static ScmEntry At(string name, string file) => + Entries.Any with { ServiceName = name, DisplayName = name, BinaryFile = Reading.Present(file) }; + + /// An inspector that answers nothing itself and counts how many passes asked it for one. + private sealed class OnlyThroughAPass : IBinaryInspector + { + internal int Passes { get; private set; } + + public Reading ReadSignature(string file) => + throw new InvalidOperationException("Asked the window's own inspector rather than one for this pass."); + + public Reading ReadFileVersion(string file) => + throw new InvalidOperationException("Asked the window's own inspector rather than one for this pass."); + + public Reading ReadHash(string file) => + throw new InvalidOperationException("Asked the window's own inspector rather than one for this pass."); + + public IBinaryInspector ForOnePass() + { + Passes++; + + return new FakeBinaryInspector(); + } + } +} diff --git a/tests/Bws.Gui.Tests/KeptFileAnswersTests.cs b/tests/Bws.Gui.Tests/KeptFileAnswersTests.cs new file mode 100644 index 0000000..532e24d --- /dev/null +++ b/tests/Bws.Gui.Tests/KeptFileAnswersTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Concurrent; +using Bws.Core; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// What a full reading keeps about files, and when it keeps nothing - the owner's S-1 decision. +/// +/// Written 2026-09-29, W4 of the performance series, backlog 466. Until then every full +/// reading verified every signature again, about 12 s of processor over 797 entries on the machine +/// it was measured on. The owner decided: after a plan, after a change to what is installed, and +/// when a question needs a family the window has not read, the window keeps what it knows about a +/// file whose path did not change - and F5 verifies everything again, always. `ADR-13` carries the +/// decision and its price. Each of the four ways a full reading starts has its test here. +/// +/// Counted rather than timed, for the reason gives: what the +/// decision is about is WHICH files are verified, and a count says that on any machine. +/// +public sealed class KeptFileAnswersTests +{ + private const string Spooler = @"C:\Windows\System32\spoolsv.exe"; + private const string TimeService = @"C:\Windows\System32\w32time.dll"; + + [Fact] + public async Task A_plan_keeps_what_the_window_knows_about_files() + { + var (model, inspector, _) = await Verified(); + + await model.LoadKeepingAsync(); + + // Not one file asked about again, and the answers are still on the rows - the question + // that needed them still has both entries in it. + Assert.Equal(2, inspector.Asked.Count); + Assert.Equal(2, model.Rows.Count); + } + + [Fact] + public async Task F5_verifies_every_file_again() + { + // The other half of the decision, and the one that keeps the price bounded: whatever went + // stale since the last F5 goes away at the next one. + var (model, inspector, _) = await Verified(); + + await model.LoadAsync(); + + Assert.Equal(4, inspector.Asked.Count); + Assert.Equal(2, inspector.Asked.Count(file => file == Spooler)); + } + + [Fact] + public async Task After_something_is_installed_only_its_file_is_verified() + { + var (model, inspector, machine) = await Verified(); + const string Installed = @"C:\Program Files\Vendor\agent.exe"; + + machine.Install(Rows.Entry("VendorAgent") with { BinaryFile = Reading.Present(Installed) }); + + await model.RefreshAsync(); + + Assert.Equal([Installed], inspector.Asked.Skip(2)); + Assert.Equal(3, model.Rows.Count); + } + + [Fact] + public async Task A_column_that_needs_another_family_does_not_verify_signatures_again() + { + var inspector = new CountingInspector(); + var memory = new CountingMemory(); + var model = new MainViewModel(Machine(), new SteppedClock(), inspector, memory); + var columns = new ColumnBar(); + + model.ColumnsNeed = () => columns.Needs; + + await model.LoadAsync(); + + model.QueryText = "signed:no"; + + await model.RefreshAsync(); + + columns.Choices.Single(choice => choice.Column.Id == "memory").IsShown = true; + + await model.RefreshAsync(); + + Assert.True(memory.Asked > 0, "Turning the memory column on did not read memory."); + Assert.Equal(2, inspector.Asked.Count); + } + + /// A window over two entries, with both files verified once by a question that needed them. + private static async Task<(MainViewModel Model, CountingInspector Inspector, LiveMachine Machine)> Verified() + { + var inspector = new CountingInspector(); + var machine = Machine(); + var model = new MainViewModel(machine, new SteppedClock(), inspector, new CountingMemory()); + + await model.LoadAsync(); + + model.QueryText = "signed:no"; + + await model.RefreshAsync(); + + Assert.Equal(2, inspector.Asked.Count); + + return (model, inspector, machine); + } + + private static LiveMachine Machine() => new( + Rows.Entry("Spooler") with { BinaryFile = Reading.Present(Spooler) }, + Rows.Entry("W32Time") with { BinaryFile = Reading.Present(TimeService) }); + + /// + /// Says every file is unsigned, so both entries answer signed:no, and remembers which files it + /// was asked about. Concurrent, because the pass asks several at once. + /// + private sealed class CountingInspector : IBinaryInspector + { + internal ConcurrentQueue Asked { get; } = []; + + public Reading ReadSignature(string file) + { + Asked.Enqueue(file); + + return Reading.Present(new BinarySignature(SignatureStatus.NotSigned, 0, "Someone")); + } + + public Reading ReadFileVersion(string file) => Reading.Present("1.0.0.0"); + + public Reading ReadHash(string file) => Reading.Present(new string('a', 64)); + } + + private sealed class CountingMemory : IProcessMemoryReader + { + private int _asked; + + internal int Asked => _asked; + + public Reading Read(int processId) + { + Interlocked.Increment(ref _asked); + + return Reading.Present(new ProcessMemory(1024, 2048, 1)); + } + } +}