Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions src/Bws.Core/IBinaryInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,22 @@ public interface IBinaryInspector
/// Absent when the path names nothing on disk, denied when it is there and unreadable.
/// </summary>
Reading<string> ReadHash(string file);

/// <summary>
/// The inspector ONE pass of the second phase asks through - backlog 468, 2026-09-29.
///
/// <b>Whatever an inspector remembers between files lives as long as one pass and no longer.</b>
/// 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.
///
/// <b>This rather than the caller clearing the memory</b>, 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.
/// </summary>
IBinaryInspector ForOnePass() => this;
}
68 changes: 66 additions & 2 deletions src/Bws.Core/SecondPass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,10 @@ public static IReadOnlyList<ScmEntry> 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<ScmEntry>(entries.Count);

foreach (var entry in entries)
Expand Down Expand Up @@ -117,7 +119,12 @@ private static List<string> Distinct(IReadOnlyList<ScmEntry> 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!);
}
Expand Down Expand Up @@ -165,6 +172,60 @@ private static Dictionary<string, Answer> Ask(
private readonly record struct Answer(
Reading<BinarySignature> Signature, Reading<string> Version, Reading<string> Hash);

/// <summary>
/// A fresh listing, with the answers an earlier reading already had about the SAME FILES.
///
/// <b>The owner's decision S-1 of 2026-09-28, recorded in `ADR-13`</b>, 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.
///
/// <b>Keyed by the FILE, never by the entry</b>: 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. <b>"Not read" is not an answer</b> and is not
/// kept: a file on another machine that was skipped is asked about again.
///
/// <b>The price, accepted by the owner before the decision:</b> a file replaced by somebody else
/// between F5 and a plan keeps its old verdict until the next F5.
/// </summary>
public static IReadOnlyList<ScmEntry> Keep(IReadOnlyList<ScmEntry> fresh, IEnumerable<ScmEntry> earlier)
{
ArgumentNullException.ThrowIfNull(fresh);
ArgumentNullException.ThrowIfNull(earlier);

var known = new Dictionary<string, Answer>(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<ScmEntry>(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;
}

/// <summary>Whether a pass - this one or an earlier one kept by <see cref="Keep"/> - already answered for this entry.</summary>
private static bool Answered(ScmEntry entry) => entry.Signature.Outcome != ReadOutcome.NotRead;

private static ScmEntry Fill(ScmEntry entry, Dictionary<string, Answer> answers)
{
switch (entry.BinaryFile.Outcome)
Expand Down Expand Up @@ -192,6 +253,9 @@ private static ScmEntry Fill(ScmEntry entry, Dictionary<string, Answer> answers)
BinaryHash = Reading<string>.Denied(entry.BinaryFile.ErrorCode, entry.BinaryFile.Reason!)
};

case ReadOutcome.Present when Answered(entry):
return entry;

case ReadOutcome.Present:
var answer = answers[entry.BinaryFile.Value!];

Expand Down
13 changes: 13 additions & 0 deletions src/Bws.Core/WindowsBinaryInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
/// <b>ONE PASS LONG SINCE 2026-09-29, and until then as long as the window</b> - 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
/// <see cref="ForOnePass"/>, 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.
/// </summary>
private readonly ConcurrentDictionary<string, string?> _publishers = new(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// A fresh inspector with nothing remembered, for one pass of the second phase - see
/// <see cref="IBinaryInspector.ForOnePass"/>. It costs one empty dictionary.
/// </summary>
public IBinaryInspector ForOnePass() => new WindowsBinaryInspector(networkPaths);

/// <summary>
/// Whether this file is one nobody asked us to reach for.
///
Expand Down
5 changes: 3 additions & 2 deletions src/Bws.Gui/MainWindow.Carrying.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ internal async Task<bool> 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;
}
Expand Down
15 changes: 13 additions & 2 deletions src/Bws.Gui/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public async Task LoadAsync()
public Task LoadAsync() => ReadMachineAsync(Relisting.Afresh);

/// <summary>
/// Reads the machine in full after a plan, keeping what the window already knows about files.
///
/// <b>The owner's S-1 decision, 2026-09-28, recorded in `ADR-13`.</b> 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 <see cref="LoadAsync()"/> and verifies everything.
/// </summary>
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
Expand Down
7 changes: 4 additions & 3 deletions src/Bws.Gui/ViewModels/Readings.SecondPhase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,10 @@ private IReadOnlyList<ScmEntry> Fill(IReadOnlyList<ScmEntry> entries, ExtraRead
/// <b>Answered by reading the machine again rather than by filling in what is held.</b> 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. <b>Since 2026-09-29 that reading keeps what the window knows
/// about files</b> (<see cref="Relisting.Keeping"/>, the owner's word), so turning a column on
/// no longer verifies every signature again - only F5 does.
/// </summary>
internal bool WantsMore() => Asked();

Expand Down
33 changes: 25 additions & 8 deletions src/Bws.Gui/ViewModels/Readings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
internal async Task LoadAsync()
/// <param name="how">
/// Afresh for the first look and F5, keeping file answers after a plan - <see cref="Relisting"/>.
/// </param>
internal async Task LoadAsync(Relisting how)
{
if (_reading)
{
Expand All @@ -191,7 +194,7 @@ internal async Task LoadAsync()

try
{
await LoadEverything().ConfigureAwait(true);
await LoadEverything(how).ConfigureAwait(true);
}
finally
{
Expand All @@ -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.
/// </summary>
private async Task LoadEverything()
private async Task LoadEverything(Relisting how)
{
Says.Status = Texts.Of("gui.status.reading");

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions src/Bws.Gui/ViewModels/Relisting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Bws.Gui.ViewModels;

/// <summary>
/// How a full reading of the machine treats what the window already knows about files.
///
/// <b>Two ways since 2026-09-29, the owner's S-1 decision recorded in `ADR-13`.</b> 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.
/// </summary>
internal enum Relisting
{
/// <summary>
/// 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.
/// </summary>
Afresh,

/// <summary>
/// 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 <c>SecondPass.Keep</c>.
/// </summary>
Keeping
}
Loading
Loading