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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ is not part of this repository.

### Fixed

- A restart no longer starts, at its end, a service this run did not stop - one somebody else was
already stopping when the step came, or one that was starting and then stopped by itself. The step
asked for nothing and only watched the service get there, and its line in a terminal now says so -
the service "got there by itself" and "nothing was asked".
- A step whose request Windows took, after which the service could no longer be read, now counts as
something the run may have done. It used to read as a refused request, so a restart did not try to
start the service again and the list of what undoes the run left it out. Both the terminal and the
window now say the request was taken and that where the service went is not known.
- Interrupting a run - the Interrupt button in the window, or the first Ctrl+C in a terminal - while a
step is getting ready no longer lets that step send its request. Before asking for anything a step
reads the service, and before ending a process it reads the whole process, and a press during those
readings used to be noticed only after the request had gone. Such a step is now held as one not
reached, a change of startup type included.
- A restart no longer stops a service that was set to Disabled after its plan was shown, which Windows
would then refuse to start again. Just before the first step the run reads the startup type of
everything it would stop and start again, and when one is Disabled it changes nothing and says the
machine changed since the plan was shown. The window used to build the plan from the startup type it
read at the last full refresh.
- Setting a service to Disabled together with stopping it now always stops it, also when the plan read
the service as stopped. One started between the plan and the run used to be left running.
- `bws kill`, and Force stop and Force restart in the window, read once more, just before ending a
process, whether Windows marks the process critical and what Windows does to each service in it once
the process dies (its recovery actions). When the process became critical, when either could not be
read, or when any of those recovery actions differ from what the plan showed, the process is not
ended and the step says why. It used to go ahead on what the plan had read, however long before.
- `bws kill`, and Force stop and Force restart in the window, refuse to end the process of a service
that can share its process while Windows does not show this account every service that could share
one. Windows leaves such a service out of the list without an error, so the plan could name fewer
services than the ending would take down. The list does not say which process a hidden service
lives in, so on such a machine every shared process is refused, even one holding a single service.
The refusal names the services Windows did not show, or says that the list of them could not be
read. Where an account is shown every service - as an administrator was on the machine this was
measured on - nothing changes.
- `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
Expand Down
2 changes: 1 addition & 1 deletion src/Bws.Cli/Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ internal static PlanRun Carry(OperationPlan plan, TimeSpan timeout)

try
{
return new PlanRunner(new WindowsScmControl(), new SystemClock()).Run(
return new PlanRunner(new WindowsScmControl(), new SystemClock(), new WindowsEndingFactsReader()).Run(
plan,
timeout,
interruption.Token,
Expand Down
14 changes: 12 additions & 2 deletions src/Bws.Cli/PlanText.Aftermath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ private static string Restarting(PlanWarning warning)
}

/// <summary>
/// The three refusals. <b>An unreadable consequence with no names is the process itself</b> - whether it
/// is critical could not be read - so it gets a sentence of its own rather than an empty list.
/// The four refusals - the fourth since 2026-10-27, neighbours the manager did not list (logic report R04).
/// <b>An unreadable consequence with no names is the process itself</b> - whether it is critical could not
/// be read - so it gets a sentence of its own rather than an empty list.
/// </summary>
private static string Aftermath(PlanProblem problem) => problem.Kind switch
{
Expand All @@ -83,6 +84,15 @@ private static string Restarting(PlanWarning warning)
: "cli.plan.problem.aftermathUnreadable.many",
problem.ServiceName, Join(problem.Related)),

// No names is the list of names itself refused, logic report R04.
PlanProblemKind.NeighboursUnreadable => Texts.Of(
problem.Related.Count == 0
? "cli.plan.problem.neighboursUnreadable.list"
: problem.Related.Count == 1
? "cli.plan.problem.neighboursUnreadable.one"
: "cli.plan.problem.neighboursUnreadable.many",
problem.ServiceName, Join(problem.Related)),

_ => throw new ArgumentOutOfRangeException(
nameof(problem), problem.Kind, EquivalentCommand.Unhandled)
};
Expand Down
11 changes: 10 additions & 1 deletion src/Bws.Cli/PlanText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,12 @@ private static string Render(
/// </summary>
internal static string Describe(StepResult result) => result.Outcome switch
{
StepOutcome.Succeeded => Texts.Of("cli.run.outcome.succeeded", Took(result.Milliseconds)),
// Two sentences for one outcome since 2026-10-26 (logic report R01): an entry that was already on
// its way and was only watched arrive is where the step wanted it, and this tool did not send it -
// which is also why a restart then says it never stopped it.
StepOutcome.Succeeded => result.ArrivedUnasked
? Texts.Of("cli.run.outcome.arrivedUnasked", Took(result.Milliseconds))
: Texts.Of("cli.run.outcome.succeeded", Took(result.Milliseconds)),

// Never without words: a refusal is only ever built from a code and the system's own
// sentence for it, together. A start that fell over is not a refusal - the manager took the
Expand All @@ -156,6 +161,10 @@ private static string Render(
StepOutcome.Failed when result.StartedAgain => Texts.Of(
"cli.run.outcome.startedAgain", result.ProcessId.Value, Took(result.Milliseconds)),

// Not "refused" - the manager took the request, and the READING was refused afterwards (R02).
StepOutcome.Failed when result.LostSight => Texts.Of(
"cli.run.outcome.lostSight", result.Error!, result.ErrorCode),

StepOutcome.Failed => result.StoppedWhileStarting
? Texts.Of(
"cli.run.outcome.stoppedWhileStarting", result.Error!, result.ErrorCode, Took(result.Milliseconds))
Expand Down
5 changes: 5 additions & 0 deletions src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,11 @@
"cli.run.putBack.line": " {0}",

"cli.run.outcome.succeeded": "done in {0}",
"cli.run.outcome.arrivedUnasked": "already on its way there and got there by itself in {0} - nothing was asked",
"cli.run.outcome.failed": "refused: {0} (error {1})",
"cli.run.outcome.stoppedWhileStarting": "did not start - it stopped again after {2}, exit code {1}: {0}",
"cli.run.outcome.startedAgain": "the process was ended, and Windows started the service again at once, in process {0}, after {1}",
"cli.run.outcome.lostSight": "asked, and then it could not be read, so where it went is not known: {0} (error {1})",
"cli.run.outcome.timedOut": "gave up after {0}, still {1}",
"cli.run.outcome.timedOut.process": "gave up after {0}, still {1}, held by process {2}",
"cli.run.outcome.alreadyThere": "already there, nothing to do",
Expand Down Expand Up @@ -180,6 +182,9 @@
"cli.plan.problem.aftermathUnreadable.process": "Could not read whether the machine survives losing the process {0} runs in, so there is no plan.",
"cli.plan.problem.aftermathUnreadable.one": "Could not read what Windows does to {1} once its process dies - it could restart the computer - so there is no plan.",
"cli.plan.problem.aftermathUnreadable.many": "Could not read what Windows does to these once their process dies - it could restart the computer - so there is no plan: {1}",
"cli.plan.problem.neighboursUnreadable.list": "Could not read which services Windows keeps, so the list of what ending the process {0} runs in would take down could be shorter than the truth. There is no plan. If this prompt is not elevated, an elevated one may be able to read them.",
"cli.plan.problem.neighboursUnreadable.one": "Windows does not show this account the service {1}, and it could live in the process {0} runs in - so the list of what ending that process would take down could be shorter than the truth. There is no plan. If this prompt is not elevated, an elevated one may be shown it, unless its own permissions keep administrators out.",
"cli.plan.problem.neighboursUnreadable.many": "Windows does not show this account these services, and any of them could live in the process {0} runs in - so the list of what ending that process would take down could be shorter than the truth. There is no plan. If this prompt is not elevated, an elevated one may be shown them, unless their own permissions keep administrators out: {1}",

"cli.column.name": "NAME",
"cli.column.displayName": "DISPLAY NAME",
Expand Down
51 changes: 50 additions & 1 deletion src/Bws.Core/EndingFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,37 @@ public static EndingFacts NobodyAsked() =>
/// <c>INFINITE</c>, a count that starts again only when the machine does. Zero is a value Microsoft does not
/// describe, and measured on the throwaway machine on 2026-10-07 it makes every failure the first.
/// </param>
public sealed record RecoveryList(IReadOnlyList<RecoveryItem> Items, TimeSpan ResetPeriod);
public sealed record RecoveryList(IReadOnlyList<RecoveryItem> Items, TimeSpan ResetPeriod)
{
/// <summary>
/// Whether two readings of a recovery list say the same thing - the same outcome and, when there is a
/// list, the same items in the same order and the same reset period.
///
/// <b>Written out rather than left to the record</b>, because the record compares its list of items by
/// reference, and two readings of one unchanged list are two lists. Asked by the run just before it
/// ends a process, against the reading the preview was built from (logic report R05, 2026-10-26).
/// </summary>
public static bool Same(Reading<RecoveryList> first, Reading<RecoveryList> second)
{
if (first.Outcome != second.Outcome)
{
return false;
}

return !first.IsPresent
|| (first.Value!.ResetPeriod == second.Value!.ResetPeriod && first.Value.Items.SequenceEqual(second.Value.Items));
}
}

/// <summary>
/// One entry that dies with an ended process, and its recovery list as the manager answered - the pair the
/// preview's recovery sentences are made from and the run compares against.
///
/// <b>Public since 2026-10-26</b>, when it began travelling on the step that ends the process
/// (<see cref="Planning.PlanStep.Recovery"/>, logic report R05). Until then it lived inside the class that
/// worked out the warnings and went no further than the plan being built.
/// </summary>
public readonly record struct RecoveryReading(string ServiceName, Reading<RecoveryList> Actions);

/// <summary>
/// What one item of the recovery list does when an entry's process dies without the entry saying it
Expand Down Expand Up @@ -192,6 +222,22 @@ public interface IEndingFactsReader
/// the same shape as one known to be short.
/// </summary>
Reading<RecoveryList> ReadRecovery(string serviceName);

/// <summary>
/// The entries the manager keeps, could share a process, and did not list to this account - the names
/// missing from <paramref name="listed"/> that exist and that this account may not query.
///
/// <b>Asked because the listing can be short with nothing failing</b> (logic report R04, 2026-10-26).
/// Microsoft says <c>EnumServicesStatusEx</c> silently omits every service the caller lacks
/// <c>SERVICE_QUERY_STATUS</c> for, so a plan that ends a shared process cannot learn from the listing
/// alone that nobody else lives there.
///
/// <b>Present and empty is the ordinary answer</b> - measured on the owner's machine on 2026-10-26, under
/// elevation the listing leaves out nothing. Denied when the list of names to check could not be read,
/// and the plan refuses on that, for the reason <see cref="ReadRecovery"/> gives.
/// </summary>
/// <param name="listed">Every name the listing the plan was built from holds.</param>
Reading<IReadOnlyList<string>> ReadUnlisted(IEnumerable<string> listed);
}

/// <summary>
Expand All @@ -209,4 +255,7 @@ internal sealed class NobodyToAsk : IEndingFactsReader

public Reading<RecoveryList> ReadRecovery(string serviceName) =>
Reading<RecoveryList>.NotRead();

public Reading<IReadOnlyList<string>> ReadUnlisted(IEnumerable<string> listed) =>
Reading<IReadOnlyList<string>>.NotRead();
}
10 changes: 10 additions & 0 deletions src/Bws.Core/IScmControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,14 @@ public interface IScmControl
/// question and the same answer as <see cref="IScmCatalog.ReadDependents"/>, for the same step.
/// </summary>
Reading<IReadOnlyList<string>> ReadDependents(string serviceName);

/// <summary>
/// The start type the manager holds for one entry now - asked by a run just before its first step, of
/// every entry the plan will start back, since 2026-10-26 (logic report R03).
///
/// <b>A reading rather than an exception on failure</b>, for the reason <see cref="ReadStatuses"/> gives,
/// and here a refusal decides nothing at all: an unreadable start type is missing information, and the
/// run goes on exactly as the builder would have.
/// </summary>
Reading<StartType> ReadStartType(string serviceName);
}
20 changes: 9 additions & 11 deletions src/Bws.Core/Planning/Aftermath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ namespace Bws.Core.Planning;
/// </summary>
internal static class Aftermath
{
/// <summary>One entry that dies with the process, and its recovery list and reset period as the manager answered.</summary>
internal readonly record struct Recovered(string ServiceName, Reading<RecoveryList> Actions);

/// <summary>
/// The last question before a plan that ends a process exists - what the manager does to the dead once
/// the process is gone, which can still refuse it - and the ending it describes when it does not.
Expand Down Expand Up @@ -51,11 +48,11 @@ internal static (PlanProblem? Refusal, ForcedStop.Ending? Ending) Weigh(
}

/// <summary>The entry first and its neighbours in the order the listing gave them - the order every sentence names them in.</summary>
private static IReadOnlyList<Recovered> Read(
private static IReadOnlyList<RecoveryReading> Read(
IEndingFactsReader processes, ScmEntry target, IReadOnlyList<ScmEntry> sharing) =>
[
.. sharing.Prepend(target)
.Select(entry => new Recovered(entry.ServiceName, processes.ReadRecovery(entry.ServiceName)))
.Select(entry => new RecoveryReading(entry.ServiceName, processes.ReadRecovery(entry.ServiceName)))
];

/// <summary>
Expand All @@ -77,7 +74,7 @@ .. sharing.Prepend(target)
/// A computer restart anywhere in a list, then a list that could not be read - in that order, because the
/// first is a fact about the machine and the second is only the absence of one.
/// </summary>
private static PlanProblem? Refusal(ScmEntry target, IReadOnlyList<Recovered> recovery)
private static PlanProblem? Refusal(ScmEntry target, IReadOnlyList<RecoveryReading> recovery)
{
var restarting = Having(recovery, RecoveryAction.RestartComputer);

Expand All @@ -98,7 +95,7 @@ .. sharing.Prepend(target)
/// <b>The first carries WHEN since 2026-09-30</b> (backlog 501) - <see cref="PlanWarning.Restarts"/>, the
/// names in <see cref="PlanWarning.Related"/> taken from the same list in the same order.
/// </summary>
internal static void AddWarnings(List<PlanWarning> warnings, ScmEntry target, IReadOnlyList<Recovered> recovery)
internal static void AddWarnings(List<PlanWarning> warnings, ScmEntry target, IReadOnlyList<RecoveryReading> recovery)
{
var restarts = Restarts(recovery);

Expand All @@ -120,7 +117,7 @@ internal static void AddWarnings(List<PlanWarning> warnings, ScmEntry target, IR
/// them - WSearch names 30 s five times and comes out as one - and, since 2026-10-07, which failures they
/// are when not every one restarts it (backlog 541).
/// </summary>
private static List<RecoveryRestart> Restarts(IReadOnlyList<Recovered> recovery) =>
private static List<RecoveryRestart> Restarts(IReadOnlyList<RecoveryReading> recovery) =>
[
.. recovery
.Where(one => one.Actions.IsPresent)
Expand Down Expand Up @@ -217,14 +214,15 @@ .. restarts.Where(one =>

/// <summary>
/// Where the step that ended the process is, when it ended it - arrived, timed out watching the entry
/// after the call worked, or answered by Windows starting the entry again at once.
/// after the call worked, lost sight of it after the call worked (since 2026-10-26, logic report R02),
/// or answered by Windows starting the entry again at once.
/// </summary>
private static int? EndedAt(IReadOnlyList<StepResult> results)
{
for (var index = 0; index < results.Count; index++)
{
if (results[index] is { Step.Operation: StepOperation.Terminate } result
&& (result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut || result.StartedAgain))
&& (result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut || result.StartedAgain || result.LostSight))
{
return index;
}
Expand Down Expand Up @@ -263,6 +261,6 @@ private static void Warn(List<PlanWarning> warnings, ScmEntry target, List<strin
/// nothing. The restart warning says which failures since 2026-10-07 (<see cref="Only"/>) - the program
/// and the unnamed item still say "anywhere", backlog 543.
/// </summary>
private static List<string> Having(IReadOnlyList<Recovered> recovery, RecoveryAction action) =>
private static List<string> Having(IReadOnlyList<RecoveryReading> recovery, RecoveryAction action) =>
[.. recovery.Where(one => one.Actions.IsPresent && one.Actions.Value!.Items.Any(item => item.Action == action)).Select(one => one.ServiceName)];
}
2 changes: 1 addition & 1 deletion src/Bws.Core/Planning/CriticalEntries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ internal static void AddWarnings(
ServiceAction action,
IReadOnlyList<ScmEntry> cascade)
{
if (PlanBuilder.StopsPolitely(action, target))
if (PlanBuilder.StopsPolitely(action))
{
var stopping = Named([.. cascade, target]);

Expand Down
11 changes: 9 additions & 2 deletions src/Bws.Core/Planning/ForcedStop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ internal readonly record struct Ending(
IReadOnlyList<ScmEntry> Sharing,
bool Immediate,
long? CreatedAt,
IReadOnlyList<Aftermath.Recovered> Recovery);
IReadOnlyList<RecoveryReading> Recovery);

internal static bool Asked(ActionKind kind) =>
kind is ActionKind.ForceStop or ActionKind.ForceRestart;
Expand Down Expand Up @@ -104,6 +104,12 @@ internal static (PlanProblem? Refusal, Ending? Ending) Decide(
return (Because(PlanProblemKind.CascadeUnreadable, target), null);
}

// The same rule for a casualty list short without anybody saying so (logic report R04).
if (ProcessNeighbours.Unlisted(processes, entries, target) is { } unlisted)
{
return (unlisted, null);
}

if (InTheWay(action, target, blocking) is { } inTheWay)
{
return (inTheWay, null);
Expand Down Expand Up @@ -360,7 +366,8 @@ internal static void AddSteps(
ending.Immediate ? StepReason.Requested : StepReason.Escalation,
ProcessId: ending.ProcessId,
ProcessCreatedAt: ending.CreatedAt,
TakesWithIt: [.. ending.Sharing.Select(entry => entry.ServiceName)]));
TakesWithIt: [.. ending.Sharing.Select(entry => entry.ServiceName)],
Recovery: ending.Recovery));
}

/// <summary>
Expand Down
Loading
Loading