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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ is not part of this repository.
- A minimised window no longer reads the list of services every second. It reads it again the
moment it is restored, so the list is current as soon as it is on screen. A window started
minimised, from a shortcut set to "Run: Minimized", reads it once and then waits the same way.
- Stopping, starting and restarting a service, from the window or with `bws`, no longer waits
about a quarter of a second longer than the service needs on every step. A service that stops
in 10 ms is now reported done after a few tens of milliseconds, where it used to be reported
after about 260, so a restart with a cascade of dependents finishes seconds sooner. The
`milliseconds` of each step in the JSON of a plan is smaller for the same reason. How long a
service is given before it is reported as not responding has not changed.
- 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.

## [0.3.0] - 2026-09-25

Expand Down
6 changes: 3 additions & 3 deletions src/Bws.Cli/CommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@ internal sealed partial record CommandLine
/// <summary>
/// Whether the listing should go and read who depends on each entry.
///
/// Asked for rather than always read: a call per entry, measured 2026-09-28 at 148-155 ms over
/// 797 entries, against 107-113 ms for the whole reading on sixteen processors - more than the
/// listing it sits behind. A query naming the
/// Asked for rather than always read: a call per entry, several at once since 2026-09-29 -
/// 19-40 ms over 797 entries on sixteen processors, against 107-113 ms for the whole reading and
/// 155-167 ms when it asked one at a time. A query naming the
/// field turns it on by itself, exactly as one about signatures or memory does.
/// </summary>
internal bool RequiredBy { get; private init; }
Expand Down
2 changes: 1 addition & 1 deletion src/Bws.Cli/OptionSurface.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ internal static readonly (string Option, CommandKind[] Verbs)[] Surface =

// Listing only, and NOT the same switch as --dependents further down. That one is an
// instruction to a write verb - take the services standing on this one with you. This is
// a reading, and it costs a call per entry: 148-155 ms over 797 entries, 2026-09-28.
// a reading, and it costs a call per entry: 19-40 ms over 797 entries, several at once, 2026-09-29.
("--required-by", [CommandKind.List]),

// The two verbs that resolve a launch path against the disk. A plan does not - it
Expand Down
4 changes: 2 additions & 2 deletions src/Bws.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,8 @@
entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths));
inspected = stopwatch.ElapsedMilliseconds - before;

// AND WHO DEPENDS ON EACH ENTRY, EVERY TIME, on the argument above and for a twentieth of
// the price - RequiredByPass carries the measurement. No switch here: one taken without it
// AND WHO DEPENDS ON EACH ENTRY, EVERY TIME, on the argument above and for a small fraction
// of the price - RequiredByPass carries the measurement. No switch here: one taken without it
// would compare against one that has it as though every service had lost its dependents.
entries = RequiredByPass.Fill(entries, catalog!);

Expand Down
5 changes: 4 additions & 1 deletion src/Bws.Core/ManagerBlocks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,11 @@ internal static unsafe List<string> ReadDependentNames(byte[] buffer, uint count
return names;
}

internal static unsafe List<EnumeratedEntry> ReadEnumerationBuffer(byte[] buffer, uint count)
internal static unsafe List<EnumeratedEntry> ReadEnumerationBuffer(ReadOnlySpan<byte> buffer, uint count)
{
// A span rather than an array since 2026-09-29, because the block now comes from a pool and
// is CUT to what the manager asked for - WindowsScmCatalog.ReadTurn. The length below is the
// length of that cut, never of the array behind it.
// However many records the manager says it wrote, never more than the room it was given.
// Trusting the count alone is the shape this project used everywhere and wrote down
// nowhere - see ReadConfigurationBuffer for the argument.
Expand Down
134 changes: 134 additions & 0 deletions src/Bws.Core/Planning/PlanRunner.Waiting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
namespace Bws.Core.Planning;

/// <summary>
/// The half of <see cref="PlanRunner"/> that watches ONE step on its way to where it was sent - how
/// often the entry is asked, and when it is given up on.
///
/// <b>Its own file since 2026-09-29</b>, when the pause between two questions stopped being one
/// number and became a ramp with a rule about promises beside it. The rest of the class walks the
/// plan and never waits for anything, so the two halves change for different reasons.
/// </summary>
public sealed partial class PlanRunner
{
/// <summary>
/// The longest pause between two questions about where the entry has got to.
///
/// Our choice, not the system's, and it carries no correctness: the deadline comes from
/// the entry's own wait hint, this only decides how soon we notice. Short because
/// somebody is watching a terminal, and asking is cheap - five calls to the manager (open
/// it, open the entry, close the manager, query, close the entry - WindowsScmControl.Read),
/// 0.19-0.23 ms the lot, measured unelevated on 2026-09-28 with tools/plan-probe/read-cost.ps1.
///
/// <b>Until 2026-09-29 this was the ONLY pause, and it was most of every wait.</b> Measured on
/// the throwaway machine on 2026-09-28: Spooler and W32Time changed state in 2-50 ms and each
/// step reported 260-289 ms, because the first question goes straight after the request, is
/// almost always too early, and the next one came a whole cadence later. Backlog 466, S-6 of
/// the external performance report. The pauses now start at <see cref="FirstLook"/> and
/// double up to this.
/// </summary>
internal static readonly TimeSpan Cadence = TimeSpan.FromMilliseconds(250);

/// <summary>
/// The first pause after the question that goes straight after the request. Each pause after
/// it is twice the one before, until <see cref="Cadence"/>.
///
/// <b>Why doubling, rather than a list of pauses:</b> it keeps one promise that a list would
/// have to be checked for. The looks land at 0, 10, 30, 70, 150 and 310 ms, so an entry that
/// arrives inside that stretch is never left waiting longer than it took to get there, plus
/// this pause. A step that took 40 ms is reported at 70, not at 250. After that the pauses stay
/// at the cadence, so a wait of any length asks the manager at most four more times than it
/// did.
///
/// <b>Windows sleeps in whole ticks of its clock, about 15.6 ms by default</b>, so on a real
/// machine the first pauses come out nearer 16, 31 and 47 ms. That only moves the looks a
/// little later and changes none of the above.
/// </summary>
internal static readonly TimeSpan FirstLook = TimeSpan.FromMilliseconds(10);

/// <summary>
/// Watches an entry on its way, and decides when it has stopped going anywhere.
///
/// Two deadlines, and the entry's own comes first. Win32 documents the promise: before
/// its wait hint elapses a service will either raise its check point or change state.
/// Keeping that promise buys it a fresh wait hint, so an entry that genuinely needs a
/// minute gets one. Breaking it is the documented signal that something has gone wrong.
/// The cap is ours and only stops a plan hanging a terminal on an entry that reports
/// progress it never finishes.
/// </summary>
private StepResult WaitFor(PlanStep step, EntryStatus target, TimeSpan timeout, TimeSpan started)
{
var giveUpAt = started + timeout;
var pause = FirstLook;
var status = EntryStatus.Unknown;

// Carried alongside the status rather than read again at the end, because the point of it
// is the step that gives up: by then the entry is exactly where nobody can act on it, and
// the last process seen holding it is the only handle a person has on what to do next.
var processId = Reading<int>.NotRead();
uint? checkPoint = null;
TimeSpan? promisedBy = null;

while (true)
{
var answer = control.Read(step.ServiceName);

if (!answer.Worked)
{
return Refused(step, answer, status, processId, started);
}

var progress = answer.Progress!.Value;
var moved = checkPoint is null || progress.CheckPoint > checkPoint || progress.Status != status;

status = progress.Status;
processId = Holding(answer);

if (status == target)
{
return Result(step, StepOutcome.Succeeded, status, processId, Elapsed(started));
}

var now = clock.Elapsed;

if (moved)
{
checkPoint = progress.CheckPoint;

// A wait hint of zero is what an entry reports when it has nothing pending,
// so it is not a promise to hold anybody to. Then only our own cap applies.
promisedBy = progress.WaitHint > TimeSpan.Zero ? now + Honoured(progress.WaitHint) : null;
}

if (now >= giveUpAt || (promisedBy is not null && now >= promisedBy))
{
return Result(step, StepOutcome.TimedOut, status, processId, Elapsed(started));
}

clock.Wait(pause);
pause = pause * 2 < Cadence ? pause * 2 : Cadence;
}
}

/// <summary>
/// How long an entry's promise is held open: its wait hint, rounded UP to a whole
/// <see cref="Cadence"/>.
///
/// <b>This is the line that keeps looking sooner from turning into giving up sooner</b>, and it
/// is new with the doubling pauses, 2026-09-29. Until then the entry was only looked at once a
/// cadence, so a promise was in effect honoured to the next whole cadence - an entry that said
/// 50 ms had 250 to keep its word, and one that said 300 had 500. Services exist that promise
/// less than they need, and to the person reading it a step reported as timed out that then
/// finished is a failure on a machine that is fine. The costly mistake is that one, and waiting
/// a fraction of a second longer on an entry that really has hung is the cheap one. So the
/// deadline stays exactly where it always was, and only how often it is checked changed.
///
/// <b>Not applied to our own cap</b>, which is not the entry's promise. It is still checked at
/// the first look after it runs out, as it always was.
///
/// Whole ticks rather than a division of two spans, because a hint that is already a whole
/// cadence has to come back unchanged, and integer arithmetic says so without a rounding
/// question.
/// </summary>
private static TimeSpan Honoured(TimeSpan waitHint) =>
TimeSpan.FromTicks((waitHint.Ticks + Cadence.Ticks - 1) / Cadence.Ticks * Cadence.Ticks);
}
81 changes: 1 addition & 80 deletions src/Bws.Core/Planning/PlanRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,8 @@ namespace Bws.Core.Planning;
/// bulk operations, where there are independent things to run - here the whole plan is one
/// service and its cascade, which is a chain by construction.
/// </summary>
public sealed class PlanRunner(IScmControl control, IClock clock)
public sealed partial class PlanRunner(IScmControl control, IClock clock)
{
/// <summary>
/// How often the entry is asked where it has got to.
///
/// Our choice, not the system's, and it carries no correctness: the deadline comes from
/// the entry's own wait hint, this only decides how soon we notice. Short because
/// somebody is watching a terminal and most stops are over in well under a second, and
/// asking is cheap - five calls to the manager (open it, open the entry, close the manager,
/// query, close the entry - WindowsScmControl.Read), not the one call this said until
/// 2026-09-28.
///
/// <b>Measured on the throwaway machine on 2026-09-28, this cadence is most of every wait:</b>
/// Spooler and W32Time changed state in 2-50 ms and each step reported 260-289 ms, because the
/// first question goes straight after the request, is almost always too early, and the next
/// one is a whole cadence later. Backlog 466, S-6 of the external performance report.
/// </summary>
private static readonly TimeSpan Cadence = TimeSpan.FromMilliseconds(250);

/// <summary>
/// Runs every step, in order.
/// </summary>
Expand Down Expand Up @@ -209,17 +192,6 @@ private StepResult RunStep(PlanStep step, TimeSpan timeout)
return WaitFor(step, target, timeout, started);
}

/// <summary>
/// Watches an entry on its way, and decides when it has stopped going anywhere.
///
/// Two deadlines, and the entry's own comes first. Win32 documents the promise: before
/// its wait hint elapses a service will either raise its check point or change state.
/// Keeping that promise buys it a fresh wait hint, so an entry that genuinely needs a
/// minute gets one. Breaking it is the documented signal that something has gone wrong.
/// The cap is ours and only stops a plan hanging a terminal on an entry that reports
/// progress it never finishes.
/// </summary>

/// <summary>
/// Writes a start type, and says where the entry is while it is at it.
///
Expand All @@ -240,57 +212,6 @@ private StepResult Configure(PlanStep step, TimeSpan started)
? Result(step, StepOutcome.Succeeded, Where(seen), Holding(seen), Elapsed(started))
: Refused(step, answer, Where(seen), Holding(seen), started);
}
private StepResult WaitFor(PlanStep step, EntryStatus target, TimeSpan timeout, TimeSpan started)
{
var giveUpAt = started + timeout;
var status = EntryStatus.Unknown;

// Carried alongside the status rather than read again at the end, because the point of it
// is the step that gives up: by then the entry is exactly where nobody can act on it, and
// the last process seen holding it is the only handle a person has on what to do next.
var processId = Reading<int>.NotRead();
uint? checkPoint = null;
TimeSpan? promisedBy = null;

while (true)
{
var answer = control.Read(step.ServiceName);

if (!answer.Worked)
{
return Refused(step, answer, status, processId, started);
}

var progress = answer.Progress!.Value;
var moved = checkPoint is null || progress.CheckPoint > checkPoint || progress.Status != status;

status = progress.Status;
processId = Holding(answer);

if (status == target)
{
return Result(step, StepOutcome.Succeeded, status, processId, Elapsed(started));
}

var now = clock.Elapsed;

if (moved)
{
checkPoint = progress.CheckPoint;

// A wait hint of zero is what an entry reports when it has nothing pending,
// so it is not a promise to hold anybody to. Then only our own cap applies.
promisedBy = progress.WaitHint > TimeSpan.Zero ? now + progress.WaitHint : null;
}

if (now >= giveUpAt || (promisedBy is not null && now >= promisedBy))
{
return Result(step, StepOutcome.TimedOut, status, processId, Elapsed(started));
}

clock.Wait(Cadence);
}
}

/// <summary>
/// Ends the process the step named, having checked it is still the one the step named.
Expand Down
7 changes: 4 additions & 3 deletions src/Bws.Core/Querying/QueryField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ public enum ExtraRead
Memory = 2,

/// <summary>
/// Who breaks if each entry stops. Measured at 148-155 ms over 797 entries on 2026-09-28.
/// Who breaks if each entry stops. Measured at 19-40 ms over 797 entries on 2026-09-29, asked
/// several at once (148-155 ms one at a time, the day before).
///
/// <b>The third family, and the one that shows why this was flags rather than a yes-or-no from
/// the start.</b> It sits between the other two - a hundred and fifty milliseconds against
/// under one and against twelve seconds of processor - so a query about dependents must not send
/// the start.</b> It sits between the other two - tens of milliseconds against under one and
/// against twelve seconds of processor - so a query about dependents must not send
/// the window to open eight hundred binaries, and a question about signatures must not walk the
/// manager service by service. Each caller asks for what it needs and gets only that, which
/// <c>Readings.Fill</c> honours one flag at a time.
Expand Down
6 changes: 3 additions & 3 deletions src/Bws.Core/Querying/QueryFields.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,9 @@ private static QueryField[] BuildAll() =>
// and the one services.msc answers only by opening a service and reading a tab.
//
// IT DECLARES A FAMILY AND THE ONE ABOVE DOES NOT, which is the whole difference
// between the two directions. This takes a call per entry: measured 148-155 ms over
// 797 entries on 2026-09-28, against 107-113 ms for the entire listing. So it is
// asked for rather than always read, exactly as signatures and memory are.
// between the two directions. This takes a call per entry: 19-40 ms over 797 entries
// asked several at once (2026-09-29), against 107-113 ms for the entire listing. So it
// is asked for rather than always read, exactly as signatures and memory are.
//
// THE FIRST HOP ONLY, which a person reading a member has to know: dependents:spooler
// finds what stands directly on Spooler, not the closure. That is what the manager
Expand Down
Loading
Loading