diff --git a/CHANGELOG.md b/CHANGELOG.md
index a3637b27..4cd4c07d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/Bws.Cli/Execution.cs b/src/Bws.Cli/Execution.cs
index d6ec5468..9ba3c3f1 100644
--- a/src/Bws.Cli/Execution.cs
+++ b/src/Bws.Cli/Execution.cs
@@ -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,
diff --git a/src/Bws.Cli/PlanText.Aftermath.cs b/src/Bws.Cli/PlanText.Aftermath.cs
index 39aa3d3f..28a9cb13 100644
--- a/src/Bws.Cli/PlanText.Aftermath.cs
+++ b/src/Bws.Cli/PlanText.Aftermath.cs
@@ -62,8 +62,9 @@ private static string Restarting(PlanWarning warning)
}
///
- /// The three refusals. An unreadable consequence with no names is the process itself - 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).
+ /// An unreadable consequence with no names is the process itself - whether it is critical could not
+ /// be read - so it gets a sentence of its own rather than an empty list.
///
private static string Aftermath(PlanProblem problem) => problem.Kind switch
{
@@ -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)
};
diff --git a/src/Bws.Cli/PlanText.cs b/src/Bws.Cli/PlanText.cs
index adee8298..2378e0b6 100644
--- a/src/Bws.Cli/PlanText.cs
+++ b/src/Bws.Cli/PlanText.cs
@@ -146,7 +146,12 @@ private static string Render(
///
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
@@ -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))
diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json
index fa045ab1..781cb11e 100644
--- a/src/Bws.Cli/Resources/cli.en.json
+++ b/src/Bws.Cli/Resources/cli.en.json
@@ -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",
@@ -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",
diff --git a/src/Bws.Core/EndingFacts.cs b/src/Bws.Core/EndingFacts.cs
index 1615b45f..29bcd22d 100644
--- a/src/Bws.Core/EndingFacts.cs
+++ b/src/Bws.Core/EndingFacts.cs
@@ -113,7 +113,37 @@ public static EndingFacts NobodyAsked() =>
/// INFINITE, 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.
///
-public sealed record RecoveryList(IReadOnlyList Items, TimeSpan ResetPeriod);
+public sealed record RecoveryList(IReadOnlyList Items, TimeSpan ResetPeriod)
+{
+ ///
+ /// 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.
+ ///
+ /// Written out rather than left to the record, 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).
+ ///
+ public static bool Same(Reading first, Reading second)
+ {
+ if (first.Outcome != second.Outcome)
+ {
+ return false;
+ }
+
+ return !first.IsPresent
+ || (first.Value!.ResetPeriod == second.Value!.ResetPeriod && first.Value.Items.SequenceEqual(second.Value.Items));
+ }
+}
+
+///
+/// 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.
+///
+/// Public since 2026-10-26, when it began travelling on the step that ends the process
+/// (, logic report R05). Until then it lived inside the class that
+/// worked out the warnings and went no further than the plan being built.
+///
+public readonly record struct RecoveryReading(string ServiceName, Reading Actions);
///
/// What one item of the recovery list does when an entry's process dies without the entry saying it
@@ -192,6 +222,22 @@ public interface IEndingFactsReader
/// the same shape as one known to be short.
///
Reading ReadRecovery(string serviceName);
+
+ ///
+ /// The entries the manager keeps, could share a process, and did not list to this account - the names
+ /// missing from that exist and that this account may not query.
+ ///
+ /// Asked because the listing can be short with nothing failing (logic report R04, 2026-10-26).
+ /// Microsoft says EnumServicesStatusEx silently omits every service the caller lacks
+ /// SERVICE_QUERY_STATUS for, so a plan that ends a shared process cannot learn from the listing
+ /// alone that nobody else lives there.
+ ///
+ /// Present and empty is the ordinary answer - 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 gives.
+ ///
+ /// Every name the listing the plan was built from holds.
+ Reading> ReadUnlisted(IEnumerable listed);
}
///
@@ -209,4 +255,7 @@ internal sealed class NobodyToAsk : IEndingFactsReader
public Reading ReadRecovery(string serviceName) =>
Reading.NotRead();
+
+ public Reading> ReadUnlisted(IEnumerable listed) =>
+ Reading>.NotRead();
}
diff --git a/src/Bws.Core/IScmControl.cs b/src/Bws.Core/IScmControl.cs
index 1c74b85e..952ef55d 100644
--- a/src/Bws.Core/IScmControl.cs
+++ b/src/Bws.Core/IScmControl.cs
@@ -182,4 +182,14 @@ public interface IScmControl
/// question and the same answer as , for the same step.
///
Reading> ReadDependents(string serviceName);
+
+ ///
+ /// 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).
+ ///
+ /// A reading rather than an exception on failure, for the reason 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.
+ ///
+ Reading ReadStartType(string serviceName);
}
diff --git a/src/Bws.Core/Planning/Aftermath.cs b/src/Bws.Core/Planning/Aftermath.cs
index aeccfbad..d61bb8c6 100644
--- a/src/Bws.Core/Planning/Aftermath.cs
+++ b/src/Bws.Core/Planning/Aftermath.cs
@@ -16,9 +16,6 @@ namespace Bws.Core.Planning;
///
internal static class Aftermath
{
- /// One entry that dies with the process, and its recovery list and reset period as the manager answered.
- internal readonly record struct Recovered(string ServiceName, Reading Actions);
-
///
/// 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.
@@ -51,11 +48,11 @@ internal static (PlanProblem? Refusal, ForcedStop.Ending? Ending) Weigh(
}
/// The entry first and its neighbours in the order the listing gave them - the order every sentence names them in.
- private static IReadOnlyList Read(
+ private static IReadOnlyList Read(
IEndingFactsReader processes, ScmEntry target, IReadOnlyList sharing) =>
[
.. sharing.Prepend(target)
- .Select(entry => new Recovered(entry.ServiceName, processes.ReadRecovery(entry.ServiceName)))
+ .Select(entry => new RecoveryReading(entry.ServiceName, processes.ReadRecovery(entry.ServiceName)))
];
///
@@ -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.
///
- private static PlanProblem? Refusal(ScmEntry target, IReadOnlyList recovery)
+ private static PlanProblem? Refusal(ScmEntry target, IReadOnlyList recovery)
{
var restarting = Having(recovery, RecoveryAction.RestartComputer);
@@ -98,7 +95,7 @@ .. sharing.Prepend(target)
/// The first carries WHEN since 2026-09-30 (backlog 501) - , the
/// names in taken from the same list in the same order.
///
- internal static void AddWarnings(List warnings, ScmEntry target, IReadOnlyList recovery)
+ internal static void AddWarnings(List warnings, ScmEntry target, IReadOnlyList recovery)
{
var restarts = Restarts(recovery);
@@ -120,7 +117,7 @@ internal static void AddWarnings(List 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).
///
- private static List Restarts(IReadOnlyList recovery) =>
+ private static List Restarts(IReadOnlyList recovery) =>
[
.. recovery
.Where(one => one.Actions.IsPresent)
@@ -217,14 +214,15 @@ .. restarts.Where(one =>
///
/// 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.
///
private static int? EndedAt(IReadOnlyList 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;
}
@@ -263,6 +261,6 @@ private static void Warn(List warnings, ScmEntry target, List) - the program
/// and the unnamed item still say "anywhere", backlog 543.
///
- private static List Having(IReadOnlyList recovery, RecoveryAction action) =>
+ private static List Having(IReadOnlyList recovery, RecoveryAction action) =>
[.. recovery.Where(one => one.Actions.IsPresent && one.Actions.Value!.Items.Any(item => item.Action == action)).Select(one => one.ServiceName)];
}
diff --git a/src/Bws.Core/Planning/CriticalEntries.cs b/src/Bws.Core/Planning/CriticalEntries.cs
index 7f4e604a..1e89ee4c 100644
--- a/src/Bws.Core/Planning/CriticalEntries.cs
+++ b/src/Bws.Core/Planning/CriticalEntries.cs
@@ -83,7 +83,7 @@ internal static void AddWarnings(
ServiceAction action,
IReadOnlyList cascade)
{
- if (PlanBuilder.StopsPolitely(action, target))
+ if (PlanBuilder.StopsPolitely(action))
{
var stopping = Named([.. cascade, target]);
diff --git a/src/Bws.Core/Planning/ForcedStop.cs b/src/Bws.Core/Planning/ForcedStop.cs
index 06d4d818..4a77de3b 100644
--- a/src/Bws.Core/Planning/ForcedStop.cs
+++ b/src/Bws.Core/Planning/ForcedStop.cs
@@ -38,7 +38,7 @@ internal readonly record struct Ending(
IReadOnlyList Sharing,
bool Immediate,
long? CreatedAt,
- IReadOnlyList Recovery);
+ IReadOnlyList Recovery);
internal static bool Asked(ActionKind kind) =>
kind is ActionKind.ForceStop or ActionKind.ForceRestart;
@@ -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);
@@ -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));
}
///
diff --git a/src/Bws.Core/Planning/NetEffect.cs b/src/Bws.Core/Planning/NetEffect.cs
index a9a9ab1a..b595ffb8 100644
--- a/src/Bws.Core/Planning/NetEffect.cs
+++ b/src/Bws.Core/Planning/NetEffect.cs
@@ -58,7 +58,10 @@ public static class NetEffect
/// is not : the manager
/// accepted the request and the entry may well have arrived after we stopped watching. Leaving
/// it out would be a claim about something nobody saw, and the direction of that error is the
- /// bad one - it would stay silent about an entry somebody was left holding.
+ /// bad one - it would stay silent about an entry somebody was left holding. So does a step that
+ /// lost sight of the entry after its request was taken, since 2026-10-26 (logic report R02),
+ /// for the same reason. And an arrival nothing asked for is not a move at all - it only says
+ /// where an entry this run already moved ended up (R01, the same day).
///
/// Skipped steps moved nothing by definition, and a refusal moved nothing either - with one
/// exception since 2026-09-29: a neighbour in a process that was ended moved, whatever its own
@@ -130,7 +133,8 @@ public static IReadOnlyList Of(IEnumerable results)
/// HERE RATHER THAN IN THE RUNNER BECAUSE IT IS THE WAY BACK'S OWN QUESTION, and two answers to it
/// would be two things that have to agree - the argument this file was made for. On the owner's
/// decision of 2026-09-30 (stability report W-5) a step putting something back gives back only what the
- /// run took: a stop that arrived or timed out, or an ending that took the entry with its process. Until
+ /// run took: a stop it ASKED for that arrived, timed out or went out of sight (the last two qualifiers
+ /// since 2026-10-26, logic report R01 and R02), or an ending that took the entry with its process. Until
/// then every such step ran, and an entry that was stopped all along was started by a restart nobody
/// let begin.
///
@@ -175,8 +179,8 @@ private static (
var settings = new Dictionary(
StringComparer.OrdinalIgnoreCase);
- // Entries a stop found already stopped - the one thing that tells a neighbour who was not in
- // the process when it ended from one who died with it.
+ // Entries a stop found already stopped, or watched stop without asking - the one thing that tells
+ // a neighbour who was not in the process when it ended from one who died with it.
var foundStopped = new HashSet(StringComparer.OrdinalIgnoreCase);
var index = 0;
@@ -185,13 +189,17 @@ private static (
{
var at = index++;
- if (result is { SkippedBecause: SkipReason.AlreadyThere, Step.Operation: StepOperation.Stop })
+ if (result.Step.Operation == StepOperation.Stop
+ && (result.SkippedBecause == SkipReason.AlreadyThere || result.ArrivedUnasked))
{
foundStopped.Add(result.Step.ServiceName);
}
+ // A request the manager took counts whatever came of the watching after it - arrived, ran
+ // out of time, or lost from sight (R02, 2026-10-26). A refusal and a skip moved nothing.
if (result.Outcome != StepOutcome.Succeeded
&& result.Outcome != StepOutcome.TimedOut
+ && !result.LostSight
&& !(endingCounts && result.StartedAgain))
{
continue;
@@ -217,15 +225,37 @@ private static (
continue;
}
- var operation = result.Step.Operation;
-
- moves[name] = moves.TryGetValue(name, out var seen)
- ? (seen.First, operation, at)
- : (operation, operation, at);
+ Moved(moves, name, result.Step.Operation, at, result.ArrivedUnasked);
}
return (moves, settings);
}
+
+ ///
+ /// Counts one step's move: the first operation and the last, which is the move arithmetic Of answers.
+ ///
+ /// SOMEBODY ELSE'S MOVE, on the owner's decision of 2026-10-26 (logic report R01). An entry the
+ /// step only watched arrive says where the entry is NOW, so it ends a move this run already made - a
+ /// restart's start that found the entry being started by somebody else still leaves nothing to undo. It
+ /// never begins one: what this run never asked for is not taken down, and nothing puts it back.
+ ///
+ private static void Moved(
+ Dictionary moves,
+ string name,
+ StepOperation operation,
+ int at,
+ bool unasked)
+ {
+ if (moves.TryGetValue(name, out var seen))
+ {
+ moves[name] = (seen.First, operation, at);
+ }
+ else if (!unasked)
+ {
+ moves[name] = (operation, operation, at);
+ }
+ }
+
///
/// The neighbours an ended process took down with it, counted as moved by the step that ended it.
///
diff --git a/src/Bws.Core/Planning/OperationPlan.cs b/src/Bws.Core/Planning/OperationPlan.cs
index 4af70cfa..c5c527d8 100644
--- a/src/Bws.Core/Planning/OperationPlan.cs
+++ b/src/Bws.Core/Planning/OperationPlan.cs
@@ -264,6 +264,17 @@ public sealed record ServiceAction(
/// The same names as the warning that says so, ,
/// taken from the same list at the same moment. Empty for a process holding nobody else.
///
+///
+/// What the manager does to the entry and to each neighbour once the process a step of kind
+/// ends is gone, as it answered when the plan was built - the lists
+/// the preview's sentences about recovery were made from. Nothing at all for the other three.
+///
+/// Frozen here so the run can ask the same question again and compare, on the owner's decision of
+/// 2026-10-26 (logic report R05). A recovery list can be changed between the preview and the press -
+/// to restarting the computer, say - and until that day the ending went ahead on the preview's answer.
+/// The run now reads every list once more just before the process dies and refuses when any of them is
+/// not the one shown. Never printed: the warnings already say what the lists mean.
+///
public sealed record PlanStep(
string ServiceName,
string DisplayName,
@@ -273,7 +284,8 @@ public sealed record PlanStep(
StartSetting? From = null,
int? ProcessId = null,
long? ProcessCreatedAt = null,
- IReadOnlyList? TakesWithIt = null);
+ IReadOnlyList? TakesWithIt = null,
+ IReadOnlyList? Recovery = null);
/// Why a plan could not be made at all.
public enum PlanProblemKind
@@ -430,7 +442,19 @@ public enum PlanProblemKind
/// 2026-09-30: the preview of the one step nobody can undo is not shown when it is known to be missing
/// a consequence.
///
- AftermathUnreadable
+ AftermathUnreadable,
+
+ ///
+ /// The process is shared, and the manager did not list to this account some entries that could live in
+ /// it - or the list of names to check could not be read. Related names the entries it did not list.
+ ///
+ /// The rule holds, for a list that is short without anybody saying
+ /// so (logic report R04, the owner's decision of 2026-10-26). Microsoft's enumeration silently leaves
+ /// out every entry the caller may not query, so "who else lives here" read from it can be short with
+ /// every call succeeding, and the preview of the one step nobody can undo would name fewer casualties
+ /// than the process has.
+ ///
+ NeighboursUnreadable
}
/// A reason there is no plan. Facts only, wording belongs above.
diff --git a/src/Bws.Core/Planning/PlanBuilder.cs b/src/Bws.Core/Planning/PlanBuilder.cs
index d2a1aac5..1b0dea59 100644
--- a/src/Bws.Core/Planning/PlanBuilder.cs
+++ b/src/Bws.Core/Planning/PlanBuilder.cs
@@ -64,14 +64,11 @@ public OperationPlan Build(ServiceAction action)
// arise. Both end up with an empty list and they get there for different reasons. A setting
// carrying a stop is the exception, and it asks exactly what a plain stop asks.
var blocking = asked.Kind is ActionKind.Start
- || (asked.Kind == ActionKind.SetStartType && !StopsAlong(asked, target))
+ || (asked.Kind == ActionKind.SetStartType && !StopsAlong(asked))
? []
: StoppingOrder(target, warnings);
- // Asking to stop one service is not asking to stop seven. Without the word, the
- // ones in the way are named and left alone, and the plan says plainly that the
- // manager will refuse the stop while they run.
- var cascade = asked.IncludeDependents ? blocking : [];
+ var cascade = Cascade(asked, blocking);
// Found by looking at a real plan rather than by reasoning: stopping BFE on this
// machine drags in WdNisDrv and wtd, both kernel drivers. Refusing a driver as the
@@ -111,7 +108,9 @@ public OperationPlan Build(ServiceAction action)
return Refuse(action, PlanProblemKind.CannotComeBack, cannotComeBack);
}
- if (!asked.IncludeDependents && blocking.Count > 0)
+ // Asked of the cascade rather than of the word since 2026-10-26: a setting carrying a stop can hold
+ // the word and no cascade (above), and its dependants are then in the way like anybody's.
+ if (cascade.Count == 0 && blocking.Count > 0)
{
warnings.Add(new PlanWarning(
PlanWarningKind.DependentsInTheWay,
@@ -248,7 +247,7 @@ private static void AddSteps(
// running - exactly the plan of one step, and the failure offers a forced stop.
steps.Add(PlanSteps.Made(target, StepOperation.SetStartType, StepReason.Requested, to));
- if (StopsAlong(action, target))
+ if (StopsAlong(action))
{
AddStops(steps, cascade, target);
}
@@ -354,20 +353,39 @@ private static ServiceAction AsPlanned(ServiceAction action, ScmEntry target) =>
: action;
///
- /// Whether a startup setting carries a stop of its own entry - somebody took the offer, and the
- /// entry is not already stopped. An entry whose state could not be read gets the step, because
- /// the runner reads it again before acting and reports "already there" if it was.
+ /// Whether a startup setting carries a stop of its own entry - somebody took the offer.
+ ///
+ /// Whatever the entry was read as, since 2026-10-26 (logic report R03, the owner's decision of that
+ /// day). Until then an entry read as stopped got no stop step, and one started between the preview and the
+ /// press was set to Disabled and left running under a plan that said "and stop it". The step costs nothing
+ /// when it is not needed: the runner reads the entry before acting and reports "already there" - the
+ /// argument this line already made for an entry whose state could not be read.
+ ///
+ internal static bool StopsAlong(ServiceAction action) =>
+ action is { Kind: ActionKind.SetStartType, AlsoStop: true };
+
+ ///
+ /// The running dependants this ask takes down with it. Asking to stop one service is not asking to stop
+ /// seven: without the word, the ones in the way are named and left alone, and the plan says plainly that
+ /// the manager will refuse the stop while they run.
+ ///
+ /// NEVER ON A STARTUP SETTING, EVEN ONE CARRYING A STOP - logic report R08, 2026-10-26. The command
+ /// line refuses --dependents on start-type, the line handed back leaves it out and the window's hand-over
+ /// refuses it, so a cascade planned here was the one layer of four saying yes: a preview stopping
+ /// dependants under a command that stops none. No interface builds that ask today (the window offers
+ /// nothing for them on such a sheet), and this makes it harmless rather than merely unreached - the same
+ /// tick box a selection carries does not reach a setting.
///
- internal static bool StopsAlong(ServiceAction action, ScmEntry target) =>
- action is { Kind: ActionKind.SetStartType, AlsoStop: true } && target.Status != EntryStatus.Stopped;
+ private static IReadOnlyList Cascade(ServiceAction asked, IReadOnlyList blocking) =>
+ asked.IncludeDependents && asked.Kind != ActionKind.SetStartType ? blocking : [];
///
/// Whether this plan asks the target to stop, politely: the two stopping kinds, and a startup
/// setting carrying a stop. The forcing kinds are asked apart, because what they say about the
/// neighbours and the critical entries is said by instead.
///
- internal static bool StopsPolitely(ServiceAction action, ScmEntry target) =>
- action.Kind is ActionKind.Stop or ActionKind.Restart || StopsAlong(action, target);
+ internal static bool StopsPolitely(ServiceAction action) =>
+ action.Kind is ActionKind.Stop or ActionKind.Restart || StopsAlong(action);
private static void AddStops(List steps, IReadOnlyList cascade, ScmEntry target)
{
@@ -486,7 +504,7 @@ private void AddWarnings(
// Without this the warning would tell somebody that changing a setting leaves the
// neighbours running, which is true, irrelevant, and exactly the kind of sentence that
// teaches people to stop reading warnings.
- if (StopsPolitely(action, target) && target.EntryType == EntryType.SharedProcess)
+ if (StopsPolitely(action) && target.EntryType == EntryType.SharedProcess)
{
var neighbours = entries
.Where(entry => entry.ProcessId.IsPresent
@@ -519,7 +537,7 @@ private void AddWarnings(
// refusal next to a step that is going to be skipped for having nothing to do.
//
// A forced stop asks its neighbours too (stability report W-6): each is a stop step of its own.
- if (StopsPolitely(action, target) || ForcedStop.Asked(action.Kind))
+ if (StopsPolitely(action) || ForcedStop.Asked(action.Kind))
{
IEnumerable asked = [.. cascade, target, .. ending?.Sharing ?? []];
diff --git a/src/Bws.Core/Planning/PlanRun.cs b/src/Bws.Core/Planning/PlanRun.cs
index f62e4071..54aa1075 100644
--- a/src/Bws.Core/Planning/PlanRun.cs
+++ b/src/Bws.Core/Planning/PlanRun.cs
@@ -151,6 +151,33 @@ public sealed record StepResult
///
public bool StartedAgain { get; init; }
+ ///
+ /// The entry reached the state the step wanted WITHOUT this step asking the manager anything - it was
+ /// already on its way there when the step reached it, so somebody else had asked.
+ ///
+ /// A fact beside a success rather than another outcome, on the owner's decision of 2026-10-26
+ /// (logic report R01). The outcome stays Succeeded, because that is what it has always meant: the entry
+ /// is where the step wanted it. What changed that day is that the way back, and the step putting the
+ /// entry back, no longer count it as a move this run made - the rule the owner set on 2026-10-17 for
+ /// the same watch running out of time (finding E of the fuzzing project), now for the same watch
+ /// arriving. Until then a restart that met a dependant somebody else was stopping waited for it, and
+ /// then started it again, undoing a decision that was not this tool's. Not in the machine readable
+ /// output, like the two facts above.
+ ///
+ public bool ArrivedUnasked { get; init; }
+
+ ///
+ /// The manager took this step's request, and then the entry could not be read any more, so nobody saw
+ /// where it went. and are the refused reading's.
+ ///
+ /// Counted as a move, the way a step that ran out of time is - logic report R02, 2026-10-26. The
+ /// request was accepted and the entry may well have arrived, so leaving it out is a claim about
+ /// something nobody saw, in the bad direction. Until that day it was an ordinary failure, which the way
+ /// back read as "nothing moved": a restart whose stop was taken and then went unseen named no way back
+ /// and did not try to start the entry again. Not in the machine readable output either.
+ ///
+ public bool LostSight { get; init; }
+
/// The entry is where the step wanted it, whether or not we had to do anything.
public bool Arrived =>
Outcome == StepOutcome.Succeeded
diff --git a/src/Bws.Core/Planning/PlanRunner.ComingBack.cs b/src/Bws.Core/Planning/PlanRunner.ComingBack.cs
new file mode 100644
index 00000000..e3274ff0
--- /dev/null
+++ b/src/Bws.Core/Planning/PlanRunner.ComingBack.cs
@@ -0,0 +1,51 @@
+namespace Bws.Core.Planning;
+
+///
+/// The look a run takes before its first step at whether what it is about to take down can come back.
+///
+/// Its own file since 2026-10-26 (logic report R03, the owner's decision "a check before the run").
+/// The rest of the class carries steps out, and this asks one question of the whole plan before any of them -
+/// the same seam has for the one step that ends a process.
+///
+public sealed partial class PlanRunner
+{
+ ///
+ /// A refusal for the first step when anything this plan takes down and puts back is set to Disabled now -
+ /// or nothing, when all of it can be started again.
+ ///
+ /// THE BUILDER ALREADY REFUSES THIS, AND IT REFUSES ON WHAT IT WAS HANDED. A restart of a disabled
+ /// entry is an outage dressed as a round trip, so will not plan one - but the
+ /// window builds plans from its listing, whose start types are as old as the last full reading, because the
+ /// reading every second asks only where each entry is. An entry set to Disabled by somebody else after that
+ /// reading was restarted by the window: stopped, refused when it was to be started, and left stopped. And a
+ /// preview can sit open for as long as somebody reads it, in either interface.
+ ///
+ /// So the run asks once more, immediately before the first thing it does, and declines the whole plan
+ /// rather than carrying out the half of it that cannot be undone. It works nothing out afresh - it reads one
+ /// value for each entry a step will start back, and either the plan that was shown runs or nothing does.
+ ///
+ /// Only a start type that was READ refuses, the line the builder draws: an unreadable one is missing
+ /// information, and turning that into a refusal would be the opposite of what the four read outcomes are for.
+ /// A plan putting nothing back - a stop, a start, a setting - asks nothing.
+ ///
+ private ControlAnswer? CannotComeBack(OperationPlan plan)
+ {
+ List disabled =
+ [
+ .. plan.Steps
+ .Where(step => step is { Reason: StepReason.Restore, Operation: StepOperation.Start })
+ .Select(step => step.ServiceName)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Where(name => control.ReadStartType(name) is { IsPresent: true, Value: StartType.Disabled })
+ ];
+
+ return disabled.Count > 0
+ ? ControlAnswer.Refused(0, string.Concat(Listed(disabled), SetToDisabled))
+ : null;
+ }
+
+ /// Said in our own words, like , with the names in front.
+ private const string SetToDisabled =
+ " - set to Disabled, so it could not be started again, and nothing was stopped. Ask again to build a plan "
+ + "against the machine as it is now.";
+}
diff --git a/src/Bws.Core/Planning/PlanRunner.Ending.cs b/src/Bws.Core/Planning/PlanRunner.Ending.cs
index ade73831..28ba6fc3 100644
--- a/src/Bws.Core/Planning/PlanRunner.Ending.cs
+++ b/src/Bws.Core/Planning/PlanRunner.Ending.cs
@@ -1,14 +1,55 @@
namespace Bws.Core.Planning;
///
-/// The last look at a process before it is ended: who lives in it now, and who outside it needs them.
+/// The last look at a process before it is ended: who lives in it now, who outside it needs them, and - since 2026-10-26 - what its death sets off.
///
/// Its own file since 2026-09-30 (stability report W-6, package B2, the owner's decision of 2026-09-29
/// at package B), and the size ceiling asked for the seam as well - the file beside it holds 198 lines of
-/// code against a line of 202.
+/// code against a line of 202. , the check that calls the other two, came here on
+/// 2026-10-27, when the file beside it stood one line of code into the crowd near the ceiling.
///
public sealed partial class PlanRunner
{
+ ///
+ /// Why the process the step names must not be ended now - or nothing, when it may.
+ ///
+ /// THE PLACE THIS CLASS LOOKS AT MOST AGAIN, AND IT IS NOT THE CLASS WORKING ANYTHING OUT
+ /// AFRESH. Windows hands out process numbers again once a process is gone, and
+ /// a plan built one moment is carried out the next - so the number in the preview can, by the
+ /// time this runs, belong to something nobody has ever heard of. Refusing on a mismatch
+ /// delivers exactly the plan that was shown or nothing at all, which is the promise. Deciding
+ /// to end a DIFFERENT process would be the class inventing a step, and it does not do that.
+ ///
+ /// The window between this reading and the call is narrow and is not closed. Holding the
+ /// process open would close it, at the cost of a handle outliving the step, and the honest
+ /// answer is that a process identity Windows hands out in one piece is what this really wants
+ /// and there is not one.
+ ///
+ private ControlAnswer? Unfit(PlanStep step, ControlAnswer before)
+ {
+ var holding = Holding(before);
+
+ // THE CREATION TIME GOES DOWN WITH THE NUMBER AND THE OTHER SIDE CHECKS IT ON THE HANDLE IT
+ // KILLS WITH. This class checks that the ENTRY still names the same process, which is a
+ // different question from whether the NUMBER still names the same process - Windows gives
+ // numbers out again, and only something holding a handle can rule that out. So this half of
+ // the identity is carried rather than compared here. Backlog 323. And the process is read for
+ // who lives in it NOW before anything dies, since 2026-09-30 - Crowded says why - and for
+ // what its death sets off NOW, since 2026-10-26 - Afterwards says why.
+ return holding.IsPresent && holding.Value == step.ProcessId
+ ? Crowded(step) ?? Afterwards(step)
+ : ControlAnswer.Refused(0, ProcessMoved);
+ }
+
+ ///
+ /// Said in the plainest words available, because it is the one refusal here that is ours rather
+ /// than the system's - the same shape as the one gives for a start type
+ /// it has no number for.
+ ///
+ private const string ProcessMoved =
+ "The process behind this entry is not the one the plan named, so nothing was ended. "
+ + "Ask again to build a plan against the machine as it is now.";
+
///
/// A refusal when the process is no longer what the plan said would die with it - or nothing, when it is.
///
@@ -80,6 +121,62 @@ .. statuses.Value!
: ControlAnswer.Refused(0, string.Concat(Listed([.. needing.Distinct(StringComparer.OrdinalIgnoreCase)]), NowNeeded));
}
+ ///
+ /// A refusal when what the process's death sets off is no longer what the preview said - or nothing, when it is.
+ ///
+ /// THE PREVIEW ASKED TWO QUESTIONS OF THE PROCESS BEYOND WHO LIVES IN IT, AND UNTIL 2026-10-26 THE RUN ASKED
+ /// NEITHER AGAIN (logic report R05, the owner's decision "everything as in the preview"): whether Windows
+ /// survives losing it, and what the manager does to each entry once it is gone. The builder refuses a critical
+ /// process and a recovery list that restarts the computer, and a list changed between the preview and the
+ /// press went ahead on the preview's answer - so an ending the preview called safe could restart the machine.
+ ///
+ /// Any difference refuses, not only one that would have refused the plan. A list that gained "run a
+ /// program" or lost its restart makes the preview's sentences untrue, and the preview is the promise.
+ ///
+ /// Nothing to ask is nothing said, the builder's own rule: a runner built with nobody to ask - a test of
+ /// something else - ends the process on what the earlier checks found, as it did before this existed.
+ ///
+ private ControlAnswer? Afterwards(PlanStep step)
+ {
+ if (processes is null)
+ {
+ return null;
+ }
+
+ var critical = processes.Read(step.ProcessId!.Value).Critical;
+
+ if (critical.Outcome == ReadOutcome.Denied)
+ {
+ return ControlAnswer.Refused(critical.ErrorCode, CriticalUnchecked);
+ }
+
+ if (critical is { IsPresent: true, Value: true })
+ {
+ return ControlAnswer.Refused(0, BecameCritical);
+ }
+
+ List changed = [];
+
+ foreach (var frozen in step.Recovery ?? [])
+ {
+ var now = processes.ReadRecovery(frozen.ServiceName);
+
+ // A refused answer is not a list that changed - it is the one thing not known, and it says so with the
+ // manager's number, as the critical check above does (the review of #94).
+ if (now.Outcome == ReadOutcome.Denied)
+ {
+ return ControlAnswer.Refused(now.ErrorCode, string.Concat(frozen.ServiceName, RecoveryUnchecked));
+ }
+
+ if (!RecoveryList.Same(frozen.Actions, now))
+ {
+ changed.Add(frozen.ServiceName);
+ }
+ }
+
+ return changed.Count > 0 ? ControlAnswer.Refused(0, string.Concat(Listed(changed), RecoveryChanged)) : null;
+ }
+
private static string Listed(List names) => string.Join(", ", names);
///
@@ -97,6 +194,22 @@ .. statuses.Value!
private const string Unchecked =
"Who lives in this process could not be read just before ending it, so nothing was ended. Ask again.";
+ private const string CriticalUnchecked =
+ "Whether Windows can survive losing this process could not be read just before ending it, so nothing was "
+ + "ended. Ask again.";
+
+ private const string BecameCritical =
+ "Windows now marks this process as one it cannot run without, so nothing was ended - ending it would stop "
+ + "the machine.";
+
+ private const string RecoveryChanged =
+ " - what Windows does when their process dies changed after the plan was built, so nothing was ended. Ask "
+ + "again to see what ending it would set off now.";
+
+ private const string RecoveryUnchecked =
+ " - what Windows does when its process dies could not be read just before ending it, so nothing was ended. "
+ + "Ask again.";
+
///
/// Said when the entry was seen in another process before it was seen stopped - the manager started it
/// again at once. The ending happened, and the words say that before they say it did not last.
diff --git a/src/Bws.Core/Planning/PlanRunner.Waiting.cs b/src/Bws.Core/Planning/PlanRunner.Waiting.cs
index a7763851..f9a80c45 100644
--- a/src/Bws.Core/Planning/PlanRunner.Waiting.cs
+++ b/src/Bws.Core/Planning/PlanRunner.Waiting.cs
@@ -47,6 +47,25 @@ public sealed partial class PlanRunner
///
internal static readonly TimeSpan FirstLook = TimeSpan.FromMilliseconds(10);
+ ///
+ /// Where a step is watched to. Here with the watching since 2026-10-27, when the file that asks for
+ /// it stood one line of code into the crowd near the ceiling.
+ ///
+ private static EntryStatus Target(StepOperation operation) => operation switch
+ {
+ StepOperation.Stop => EntryStatus.Stopped,
+
+ // THE SAME TARGET AS A STOP, and that is the point of it rather than a shortcut. Ending the
+ // process is how this step gets there, and the manager noticing the process die and moving
+ // the entry is what finishes it - so the step is done when the ENTRY says so, never when
+ // the call returns.
+ StepOperation.Terminate => EntryStatus.Stopped,
+
+ StepOperation.Start => EntryStatus.Running,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(operation), operation, EquivalentCommand.Unhandled)
+ };
+
///
/// What a step does before it asks for anything, when the entry is already on its way somewhere -
/// or null, when it should go on and ask.
@@ -60,7 +79,9 @@ public sealed partial class PlanRunner
///
/// Already heading where the step wants it: nothing is asked, the entry is watched, and if it
/// arrives the step succeeded - which is what the outcome has always meant, the entry reaching the
- /// state the step asked for, not this tool sending it there. Heading the other way: it is
+ /// state the step asked for, not this tool sending it there. Marked
+ /// since 2026-10-26 (logic report R01), so the way back never counts it as this run's move.
+ /// Heading the other way: it is
/// watched until it leaves that state, and the step then goes on as though it had just found it
/// there. One that never leaves it is a failure with nothing sent, in our own words, because the
/// way back must not count as a move something this tool never asked for.
@@ -81,8 +102,9 @@ public sealed partial class PlanRunner
return watched.End switch
{
+ // Nothing was asked on this path, so an arrival is somebody else's move - ArrivedUnasked.
Settled.Over when Where(seen) == target =>
- Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)),
+ Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)) with { ArrivedUnasked = true },
Settled.Over when OnItsWay(target, Where(seen)) =>
WaitFor(step, target, timeout, started, Unasked, asked: false),
@@ -111,7 +133,8 @@ Settled.Over when OnItsWay(target, Where(seen)) =>
/// watching ended - the entry may still arrive, which is what TimedOut says - and one before any
/// request is a step never attempted. A watch that runs out of time before any request is a
/// failure in our own words rather than TimedOut, since 2026-10-17, so it is never counted as a
- /// move this tool made.
+ /// move this tool made. One that arrives before any request is marked ArrivedUnasked, and one whose
+ /// entry stops being readable after a request is marked LostSight - both since 2026-10-26.
///
private StepResult WaitFor(
PlanStep step, EntryStatus target, TimeSpan timeout, TimeSpan started, Func halted, bool asked)
@@ -130,9 +153,12 @@ private StepResult WaitFor(
return watched.End switch
{
- Settled.Unreadable => Refused(step, watched.Last, Where(seen), Holding(seen), started),
+ // After a request the manager took, losing sight of the entry is not "nothing moved" - LostSight.
+ Settled.Unreadable =>
+ Refused(step, watched.Last, Where(seen), Holding(seen), started) with { LostSight = asked },
+
Settled.Over when Where(seen) == target =>
- Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)),
+ Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)) with { ArrivedUnasked = !asked },
// The only other way an ending's watch is over: the entry is held by a process that is not the
// one ended. Nothing falls back for a stop, so this arm and the next never meet.
diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs
index 78d675a9..9405d968 100644
--- a/src/Bws.Core/Planning/PlanRunner.cs
+++ b/src/Bws.Core/Planning/PlanRunner.cs
@@ -13,8 +13,20 @@ namespace Bws.Core.Planning;
/// Nothing runs side by side. C2 promises independent work in parallel and that belongs to
/// 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.
+///
+/// Where it does look again, it only ever declines - the process an ending names (),
+/// and since 2026-10-26 whether what a restart takes down can come back (,
+/// logic report R03). Each reads the machine immediately before the first thing that cannot be undone and
+/// refuses in our own words when the preview no longer describes it, which is the plan that was shown or
+/// nothing at all - never a different plan worked out on the spot.
///
-public sealed partial class PlanRunner(IScmControl control, IClock clock)
+///
+/// What to ask about a process just before ending it - the questions the preview asked of it, asked once
+/// more, since 2026-10-26 (logic report R05). Null asks nothing, the way does with
+/// nobody to ask: both interfaces hand one to the builder and the runner alike, and a test of something
+/// else need not.
+///
+public sealed partial class PlanRunner(IScmControl control, IClock clock, IEndingFactsReader? processes = null)
{
///
/// Runs every step, in order.
@@ -79,6 +91,10 @@ public PlanRun Run(
var forwardFailed = false;
var cascadeFailed = false;
+ // Asked once, before the first step - and handed to that step only, as its refusal, because a
+ // plan that cannot put back what it takes down must take nothing down at all (R03).
+ var cannotComeBack = CannotComeBack(plan);
+
for (var index = 0; index < plan.Steps.Count; index++)
{
var step = plan.Steps[index];
@@ -106,14 +122,24 @@ public PlanRun Run(
starting?.Invoke(step, index + 1);
- var result = RunStep(step, timeout, new Halt(cancellation, abandonment));
+ var wholePlan = cannotComeBack is not null;
+ var result = cannotComeBack is { } refusal
+ ? Refused(step, refusal, EntryStatus.Unknown, Reading.NotRead(), clock.Elapsed)
+ : RunStep(step, timeout, new Halt(cancellation, abandonment));
+
+ cannotComeBack = null;
results.Add(result);
if (!result.Arrived && step.Reason != StepReason.Restore)
{
forwardFailed = true;
- cascadeFailed |= step.Reason == StepReason.Cascade;
+
+ // A REFUSAL OF THE WHOLE PLAN HOLDS THE STEPS STANDING BEHIND A FAILURE AS WELL. They go on
+ // after an ordinary failure - that is what they are for - and in a forced restart they are
+ // the neighbours and the ending, so a plan refused for what it could not put back would
+ // still have ended the process. Found by the second analysis of R03, before any run.
+ cascadeFailed |= step.Reason == StepReason.Cascade || wholePlan;
}
}
@@ -214,7 +240,7 @@ private bool Stays(OperationPlan plan)
return true;
}
- // THE QUESTION END ASKS, ASKED THE SAME WAY, and one question answers all three. An entry that
+ // THE QUESTION UNFIT ASKS, ASKED THE SAME WAY, and one question answers all three. An entry that
// stopped is held by no process - the manager answers zero, which Holding makes an absence -
// and one that cannot be read is held by nothing anybody saw. Until a mutation run on
// 2026-09-29 this spelled the stopped case out as well, and removing it changed nothing.
@@ -236,7 +262,11 @@ private StepResult RunStep(PlanStep step, TimeSpan timeout, Halt halt)
// a start type moves nothing, so there is no state to arrive at and nothing to poll.
if (step.Operation == StepOperation.SetStartType)
{
- return Configure(step, started);
+ // The last look at both ways to stop, as below - here nothing is read first, so it comes
+ // straight after the progress call the loop made.
+ return halt.Unasked(step.Reason)
+ ? Skipped(step, SkipReason.Cancelled, EntryStatus.Unknown, Reading.NotRead(), Elapsed(started))
+ : Configure(step, started);
}
var target = Target(step.Operation);
@@ -266,11 +296,25 @@ private StepResult RunStep(PlanStep step, TimeSpan timeout, Halt halt)
// ENDING A PROCESS DOES NOT GO THROUGH THE MANAGER, so it is not a Request - and the reading
// taken above is the whole reason this sits here rather than anywhere else. It is the freshest
// answer available about where the entry is and what is holding it, taken immediately before
- // anything happens.
+ // anything happens. Unfit says what else is looked at once more before the process dies.
+ var refusal = step.Operation == StepOperation.Terminate ? Unfit(step, before) : null;
+
+ // THE LAST LOOK AT BOTH WAYS TO STOP, IMMEDIATELY BEFORE ANYTHING IS SENT - logic report R09,
+ // 2026-10-26. The loop asks between steps, and a step then reads the entry and, before an ending,
+ // the whole process - so Stop pressed during those readings still sent the request after them.
+ // Nothing of this step has changed the machine yet, so it is held exactly as a step not reached.
+ if (refusal is null && halt.Unasked(step.Reason))
+ {
+ return Skipped(step, SkipReason.Cancelled, Where(before), Holding(before), Elapsed(started));
+ }
+
+ // The process the entry is held by, which Unfit has just found to be the plan's - not the plan's
+ // number as such. Equal whenever this line runs, and the difference is what a guard can see: with
+ // that check gone, this ends a process the preview never named rather than a number it did.
var asking = clock.Elapsed;
- var request = step.Operation == StepOperation.Terminate
- ? End(step, before)
- : control.Request(step.ServiceName, step.Operation);
+ var request = refusal ?? (step.Operation == StepOperation.Terminate
+ ? control.Terminate(Holding(before).Value, step.ProcessCreatedAt)
+ : control.Request(step.ServiceName, step.Operation));
var answered = Elapsed(asking);
if (!request.Worked)
@@ -315,60 +359,6 @@ private StepResult Configure(PlanStep step, TimeSpan started)
: Refused(step, answer, Where(seen), Holding(seen), started);
}
- ///
- /// Ends the process the step named, having checked it is still the one the step named.
- ///
- /// THE ONE PLACE THIS CLASS LOOKS AT SOMETHING AGAIN, AND IT IS NOT THE CLASS WORKING
- /// ANYTHING OUT AFRESH. Windows hands out process numbers again once a process is gone, and
- /// a plan built one moment is carried out the next - so the number in the preview can, by the
- /// time this runs, belong to something nobody has ever heard of. Refusing on a mismatch
- /// delivers exactly the plan that was shown or nothing at all, which is the promise. Deciding
- /// to end a DIFFERENT process would be the class inventing a step, and it does not do that.
- ///
- /// The window between this reading and the call is narrow and is not closed. Holding the
- /// process open would close it, at the cost of a handle outliving the step, and the honest
- /// answer is that a process identity Windows hands out in one piece is what this really wants
- /// and there is not one.
- ///
- private ControlAnswer End(PlanStep step, ControlAnswer before)
- {
- var holding = Holding(before);
-
- // THE CREATION TIME GOES DOWN WITH THE NUMBER AND THE OTHER SIDE CHECKS IT ON THE HANDLE IT
- // KILLS WITH. This class checks that the ENTRY still names the same process, which is a
- // different question from whether the NUMBER still names the same process - Windows gives
- // numbers out again, and only something holding a handle can rule that out. So this half of
- // the identity is carried rather than compared here. Backlog 323. And the process is read for
- // who lives in it NOW before anything dies, since 2026-09-30 - Crowded says why.
- return holding.IsPresent && holding.Value == step.ProcessId
- ? Crowded(step) ?? control.Terminate(holding.Value, step.ProcessCreatedAt)
- : ControlAnswer.Refused(0, ProcessMoved);
- }
-
- ///
- /// Said in the plainest words available, because it is the one refusal here that is ours rather
- /// than the system's - the same shape as the one gives for a start type
- /// it has no number for.
- ///
- private const string ProcessMoved =
- "The process behind this entry is not the one the plan named, so nothing was ended. "
- + "Ask again to build a plan against the machine as it is now.";
-
- private static EntryStatus Target(StepOperation operation) => operation switch
- {
- StepOperation.Stop => EntryStatus.Stopped,
-
- // THE SAME TARGET AS A STOP, and that is the point of it rather than a shortcut. Ending the
- // process is how this step gets there, and the manager noticing the process die and moving
- // the entry is what finishes it - so the step is done when the ENTRY says so, never when
- // the call returns.
- StepOperation.Terminate => EntryStatus.Stopped,
-
- StepOperation.Start => EntryStatus.Running,
- _ => throw new ArgumentOutOfRangeException(
- nameof(operation), operation, EquivalentCommand.Unhandled)
- };
-
private static EntryStatus Where(ControlAnswer answer) =>
answer.Worked ? answer.Progress!.Value.Status : EntryStatus.Unknown;
diff --git a/src/Bws.Core/Planning/ProcessNeighbours.cs b/src/Bws.Core/Planning/ProcessNeighbours.cs
index 667f3d03..1ee07cc4 100644
--- a/src/Bws.Core/Planning/ProcessNeighbours.cs
+++ b/src/Bws.Core/Planning/ProcessNeighbours.cs
@@ -76,6 +76,37 @@ internal static IReadOnlyList Of(IReadOnlyList entries, ScmE
return processId is 0 or TheKernel || processId == Environment.ProcessId ? null : processId;
}
+ ///
+ /// A refusal when the manager kept out of the listing anybody who could live in this process, and
+ /// nothing otherwise - logic report R04, the owner's decision of 2026-10-26.
+ ///
+ /// Asked of a shared process only, because an entry with its own process has no neighbours to
+ /// leave out. Asked of every unlisted entry that could share, not of the ones in this process,
+ /// because which process an entry lives in is exactly what this account was not shown. The price is
+ /// said out loud in the glossary: on a machine that keeps one shared entry from this account, every
+ /// shared process is refused an ending. Under elevation on the owner's machine nothing is kept out.
+ ///
+ /// Asked while the plan is built and not again just before the ending. An entry hidden between
+ /// the preview and the press is not seen.
+ ///
+ internal static PlanProblem? Unlisted(IEndingFactsReader processes, IReadOnlyList entries, ScmEntry target)
+ {
+ if (target.EntryType != EntryType.SharedProcess)
+ {
+ return null;
+ }
+
+ var unlisted = processes.ReadUnlisted(entries.Select(entry => entry.ServiceName));
+
+ return unlisted.Outcome switch
+ {
+ ReadOutcome.Denied => new PlanProblem(PlanProblemKind.NeighboursUnreadable, target.ServiceName, []),
+ ReadOutcome.Present when unlisted.Value!.Count > 0 =>
+ new PlanProblem(PlanProblemKind.NeighboursUnreadable, target.ServiceName, unlisted.Value),
+ _ => null
+ };
+ }
+
///
/// Whether this entry has already been asked to stop.
///
diff --git a/src/Bws.Core/Planning/StartTypeWarnings.cs b/src/Bws.Core/Planning/StartTypeWarnings.cs
index 5913438d..b8636a1b 100644
--- a/src/Bws.Core/Planning/StartTypeWarnings.cs
+++ b/src/Bws.Core/Planning/StartTypeWarnings.cs
@@ -32,7 +32,7 @@ internal static void Add(List warnings, ScmEntry target, ServiceAct
// that already stops the entry has nothing to warn about - that is what the offer was for.
if (setting == StartSetting.Disabled
&& target.Status != EntryStatus.Stopped
- && !PlanBuilder.StopsAlong(action, target))
+ && !PlanBuilder.StopsAlong(action))
{
warnings.Add(new PlanWarning(PlanWarningKind.KeepsRunning, target.ServiceName));
}
diff --git a/src/Bws.Core/WindowsEndingFactsReader.Unlisted.cs b/src/Bws.Core/WindowsEndingFactsReader.Unlisted.cs
new file mode 100644
index 00000000..77e4260c
--- /dev/null
+++ b/src/Bws.Core/WindowsEndingFactsReader.Unlisted.cs
@@ -0,0 +1,135 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security;
+using Microsoft.Win32;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+
+namespace Bws.Core;
+
+///
+/// Which entries the manager keeps and did not list to this account - since 2026-10-27, logic report R04.
+///
+/// The registry is read here as a list of names and a type, and as nothing else. The rule is to ask
+/// the services API wherever it answers, and here it does not: the one call that lists entries leaves out
+/// every entry the caller may not query, without an error (Microsoft's page on EnumServicesStatusEx),
+/// and no other call hands out names. Every name found is then put to the manager itself, which is the
+/// authority on whether it is a service and whether this account may see it.
+///
+public sealed partial class WindowsEndingFactsReader
+{
+ /// Where the manager keeps one key per entry it knows.
+ private const string ServicesKey = @"SYSTEM\CurrentControlSet\Services";
+
+ ///
+ /// SERVICE_WIN32_SHARE_PROCESS, the bit every entry that can live beside another carries - a
+ /// per-user template sharing a process carries it too, as 0x60.
+ ///
+ private const int SharesAProcess = 0x20;
+
+ ///
+ /// Every name under the services key that the listing lacks, could share a process, and that the manager
+ /// will not let this account query.
+ ///
+ /// Three answers from the manager, and only the refusal counts. Opened: the entry is visible and the
+ /// listing is merely older than it, so the run's own reading just before the ending sees it. 1060 or an
+ /// invalid name: a key the manager does not hold as an entry. Anything else: the entry exists and this
+ /// account is not shown it, which is exactly the shape the enumeration leaves out.
+ ///
+ /// The type is read before the manager is asked, so an entry with its own process costs a key open
+ /// rather than a call to the manager. A key whose type cannot be read refuses the whole answer - not
+ /// knowing whether it shares is no reason to leave it out of a casualty list.
+ ///
+ public Reading> ReadUnlisted(IEnumerable listed)
+ {
+ ArgumentNullException.ThrowIfNull(listed);
+
+ var known = new HashSet(listed, StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ using var services = Registry.LocalMachine.OpenSubKey(ServicesKey);
+
+ if (services is null)
+ {
+ return Unreadable>((int)WIN32_ERROR.ERROR_FILE_NOT_FOUND);
+ }
+
+ using var manager = PInvoke.OpenSCManager(
+ lpMachineName: null!,
+ lpDatabaseName: null!,
+ dwDesiredAccess: PInvoke.SC_MANAGER_CONNECT);
+
+ if (manager.IsInvalid)
+ {
+ return Unreadable>(Marshal.GetLastWin32Error());
+ }
+
+ List unlisted =
+ [
+ .. services.GetSubKeyNames()
+ .Where(name => !known.Contains(name) && MayShare(services, name) && Withheld(manager, name))
+ ];
+
+ return Reading>.Present(unlisted);
+ }
+ catch (Exception e) when (e is SecurityException or UnauthorizedAccessException or IOException)
+ {
+ return Unreadable>(CodeOf(e));
+ }
+ }
+
+ ///
+ /// Whether this key can be an entry living beside another. A key with no type is not an entry - 62 of 867
+ /// keys on the owner's machine on 2026-10-27 - a type without the shared bit lives alone, a key that went
+ /// since the names were read is nobody, and a type of another shape is asked about all the same.
+ ///
+ /// A key that cannot be read is not caught here: it refuses the whole answer in the caller, with
+ /// the system's number, so a key this account may not read never shortens the list in silence.
+ ///
+ private static bool MayShare(RegistryKey services, string name)
+ {
+ using var key = services.OpenSubKey(name);
+
+ return CouldShare(key?.GetValue("Type"));
+ }
+
+ ///
+ /// The decision makes, on the value alone - out on its own since the review of #94,
+ /// because a machine shows a test no key of the shape it needs, and a decision only reachable through the
+ /// registry is a decision no test can turn red.
+ ///
+ internal static bool CouldShare(object? type) => type switch
+ {
+ null => false,
+ int bits => (bits & SharesAProcess) != 0,
+ _ => true
+ };
+
+ /// Whether the manager knows this name and will not let this account query it.
+ private static bool Withheld(SafeHandle manager, string name)
+ {
+ using var service = PInvoke.OpenService(manager, name, PInvoke.SERVICE_QUERY_STATUS);
+
+ return service.IsInvalid && Withholds(Marshal.GetLastWin32Error());
+ }
+
+ ///
+ /// What a refused open of a name from the registry means: not an entry (1060, or a name the manager cannot
+ /// hold, 123), or an entry this account is not shown - every other number. Out on its own for the reason
+ /// gives.
+ ///
+ internal static bool Withholds(int code) =>
+ code is not ((int)WIN32_ERROR.ERROR_SERVICE_DOES_NOT_EXIST or (int)WIN32_ERROR.ERROR_INVALID_NAME);
+
+ private static Reading Unreadable(int code) => Reading.Denied(code, new Win32Exception(code).Message);
+
+ ///
+ /// The system's number behind a refused registry read - an access refusal when the exception carries none,
+ /// which is what is.
+ ///
+ private static int CodeOf(Exception e) =>
+ ((uint)e.HResult & 0xFFFF0000u) == 0x80070000u
+ ? e.HResult & 0xFFFF
+ : (int)WIN32_ERROR.ERROR_ACCESS_DENIED;
+}
diff --git a/src/Bws.Core/WindowsScmCatalog.Configuration.cs b/src/Bws.Core/WindowsScmCatalog.Configuration.cs
new file mode 100644
index 00000000..e4a5c8fd
--- /dev/null
+++ b/src/Bws.Core/WindowsScmCatalog.Configuration.cs
@@ -0,0 +1,91 @@
+using System.Runtime.InteropServices;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+
+namespace Bws.Core;
+
+///
+/// The configuration query itself, on a handle somebody else opened - for the listing, and since 2026-10-26
+/// for a run that asks one entry's start type before its first step (logic report R03).
+///
+/// Out of ReadConfiguration that day, and the reason is that there are now two callers. The query has
+/// two calls, a size the manager reports and a buffer whose pointers point into itself, and every one of those
+/// has been wrong here once - backlog 297 and 303. A second copy of it for the run would be a second place to
+/// get them wrong.
+///
+public sealed partial class WindowsScmCatalog
+{
+ ///
+ /// The start type the manager holds for one entry now, or the refusal - nothing else of the configuration.
+ ///
+ /// SERVICE_QUERY_CONFIG alone, the right the listing asks for this query and nothing more, so a run
+ /// is refused this exactly where the listing is.
+ ///
+ public Reading ReadStartType(string serviceName)
+ {
+ using var manager = PInvoke.OpenSCManager(
+ lpMachineName: null!,
+ lpDatabaseName: null!,
+ dwDesiredAccess: PInvoke.SC_MANAGER_CONNECT);
+
+ if (manager.IsInvalid)
+ {
+ return Refused(Marshal.GetLastWin32Error());
+ }
+
+ using var service = PInvoke.OpenService(manager, serviceName, PInvoke.SERVICE_QUERY_CONFIG);
+
+ return service.IsInvalid ? Refused(Marshal.GetLastWin32Error()) : Queried(service).StartType;
+ }
+
+ ///
+ /// What QueryServiceConfig hands over for an open entry - every field it carries read, the rest left as the
+ /// listing's own calls fill them, or refused in every field.
+ ///
+ internal static unsafe ScmConfiguration Queried(SafeHandle service)
+ {
+ // ASKED THROUGH THE RETURN VALUE, the rule Enumerate sets out in full and the one this call did not
+ // follow until 2026-09-02. Backlog 303. Deciding on the size alone reads whatever the previous call
+ // on this thread left in the last error as though it were this call's refusal.
+ var probed = PInvoke.QueryServiceConfig(service, default, out var needed);
+ var probeError = Marshal.GetLastWin32Error();
+
+ if (!probed && probeError != (int)WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER)
+ {
+ return ScmConfiguration.Refused(probeError);
+ }
+
+ if (needed == 0)
+ {
+ // The call answered and asked for no room. Every entry has a configuration, so this
+ // is not a shape the manager produces - measured over 799 entries under both tokens
+ // on 2026-09-02 and never seen once. Refused with a definite code rather than with a
+ // stale one, because there is no true thing to say about an answer that cannot happen.
+ return ScmConfiguration.Refused((int)WIN32_ERROR.ERROR_INVALID_DATA);
+ }
+
+ var buffer = new byte[needed];
+
+ // PINNED ACROSS THE CALL AND THE READING, NOT ONLY ACROSS THE CALL, and this is the whole
+ // of backlog 297. The interop wrapper pins for exactly as long as the native call runs -
+ // Windows.Win32.PInvoke.ADVAPI32.dll.g.cs puts its `fixed` INSIDE the method - so the array
+ // is loose again the moment it returns. The structure the manager wrote into it carries
+ // ABSOLUTE POINTERS into that same array, taken at the moment of the call, and the reading
+ // below follows them. A collection between the two moves the bytes to a new address and
+ // leaves those pointers aimed at where the array used to be.
+ //
+ // What that costs is the thing this whole file is careful about: an account or a launch
+ // path read from freed memory is either an access violation, which ends the process on
+ // somebody's server without a word, or worse - plausible rubbish in an audit.
+ //
+ // THE SPAN IS BUILT FROM THE PINNED POINTER RATHER THAN FROM THE ARRAY on purpose. Passing
+ // `buffer` would work identically, and would leave nothing on the page saying why the block
+ // exists - the next person would see a `fixed` whose variable nobody uses and take it out.
+ fixed (byte* pinned = buffer)
+ {
+ return PInvoke.QueryServiceConfig(service, new Span(pinned, buffer.Length), out _)
+ ? ManagerBlocks.ReadConfigurationBuffer(buffer)
+ : ScmConfiguration.Refused(Marshal.GetLastWin32Error());
+ }
+ }
+}
diff --git a/src/Bws.Core/WindowsScmCatalog.cs b/src/Bws.Core/WindowsScmCatalog.cs
index 0c935722..540dd231 100644
--- a/src/Bws.Core/WindowsScmCatalog.cs
+++ b/src/Bws.Core/WindowsScmCatalog.cs
@@ -323,53 +323,13 @@ private static unsafe ScmConfiguration ReadConfiguration(
return ScmConfiguration.Refused(Marshal.GetLastWin32Error());
}
- // ASKED THROUGH THE RETURN VALUE, the rule Enumerate below sets out in full and the one
- // this call did not follow until 2026-09-02. Backlog 303. Deciding on the size alone reads
- // whatever the previous call on this thread left in the last error as though it were this
- // call's refusal.
- var probed = PInvoke.QueryServiceConfig(service, default, out var needed);
- var probeError = Marshal.GetLastWin32Error();
-
- if (!probed && probeError != (int)WIN32_ERROR.ERROR_INSUFFICIENT_BUFFER)
- {
- return ScmConfiguration.Refused(probeError);
- }
+ var configuration = Queried(service);
- if (needed == 0)
+ // A refusal is refused in every field, the start type included, and a reading that worked
+ // always carries a start type - so this one field says which of the two came back.
+ if (configuration.StartType.Outcome == ReadOutcome.Denied)
{
- // The call answered and asked for no room. Every entry has a configuration, so this
- // is not a shape the manager produces - measured over 799 entries under both tokens
- // on 2026-09-02 and never seen once. Refused with a definite code rather than with a
- // stale one, because there is no true thing to say about an answer that cannot happen.
- return ScmConfiguration.Refused((int)WIN32_ERROR.ERROR_INVALID_DATA);
- }
-
- var buffer = new byte[needed];
- ScmConfiguration configuration;
-
- // PINNED ACROSS THE CALL AND THE READING, NOT ONLY ACROSS THE CALL, and this is the whole
- // of backlog 297. The interop wrapper pins for exactly as long as the native call runs -
- // Windows.Win32.PInvoke.ADVAPI32.dll.g.cs puts its `fixed` INSIDE the method - so the array
- // is loose again the moment it returns. The structure the manager wrote into it carries
- // ABSOLUTE POINTERS into that same array, taken at the moment of the call, and the reading
- // below follows them. A collection between the two moves the bytes to a new address and
- // leaves those pointers aimed at where the array used to be.
- //
- // What that costs is the thing this whole file is careful about: an account or a launch
- // path read from freed memory is either an access violation, which ends the process on
- // somebody's server without a word, or worse - plausible rubbish in an audit.
- //
- // THE SPAN IS BUILT FROM THE PINNED POINTER RATHER THAN FROM THE ARRAY on purpose. Passing
- // `buffer` would work identically, and would leave nothing on the page saying why the block
- // exists - the next person would see a `fixed` whose variable nobody uses and take it out.
- fixed (byte* pinned = buffer)
- {
- if (!PInvoke.QueryServiceConfig(service, new Span(pinned, buffer.Length), out _))
- {
- return ScmConfiguration.Refused(Marshal.GetLastWin32Error());
- }
-
- configuration = ManagerBlocks.ReadConfigurationBuffer(buffer);
+ return configuration;
}
var withOwnCalls = configuration with
diff --git a/src/Bws.Core/WindowsScmControl.Company.cs b/src/Bws.Core/WindowsScmControl.Company.cs
index efb48937..479d6687 100644
--- a/src/Bws.Core/WindowsScmControl.Company.cs
+++ b/src/Bws.Core/WindowsScmControl.Company.cs
@@ -32,4 +32,8 @@ public Reading> ReadStatuses()
public Reading> ReadDependents(string serviceName) =>
new WindowsScmCatalog().ReadDependents(serviceName);
+
+ /// The listing's own query on one entry, since 2026-10-26 - the argument above, for logic report R03.
+ public Reading ReadStartType(string serviceName) =>
+ new WindowsScmCatalog().ReadStartType(serviceName);
}
diff --git a/src/Bws.Gui/Carrying.cs b/src/Bws.Gui/Carrying.cs
index b9a62223..13900898 100644
--- a/src/Bws.Gui/Carrying.cs
+++ b/src/Bws.Gui/Carrying.cs
@@ -81,7 +81,7 @@ internal static Task Out(
// from a manager handed in from somewhere else would put this construction in a
// caller with no plan beside it, which is the first step towards a write that skips
// one.
- var runner = new BulkRunner(new PlanRunner(new WindowsScmControl(), new SystemClock()));
+ var runner = new BulkRunner(new PlanRunner(new WindowsScmControl(), new SystemClock(), new WindowsEndingFactsReader()));
return runner.Run(plan, ceiling, stopping, abandoning, starting: announce);
},
diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json
index 135b87d7..bef32e33 100644
--- a/src/Bws.Gui/Resources/gui.en.json
+++ b/src/Bws.Gui/Resources/gui.en.json
@@ -373,6 +373,9 @@
"gui.plan.problem.aftermathUnreadable.process": "Could not read whether the machine survives losing the process {0} runs in.",
"gui.plan.problem.aftermathUnreadable.one": "Could not read what Windows does to {1} once its process dies - it could restart the computer.",
"gui.plan.problem.aftermathUnreadable.many": "Could not read what Windows does to these once their process dies - it could restart the computer: {1}.",
+ "gui.plan.problem.neighboursUnreadable.list": "Could not read which services Windows keeps, so the list of what would go with the process {0} runs in could be shorter than the truth. If this window is not running as administrator, Restart as admin may let it read them.",
+ "gui.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 would go with that process could be shorter than the truth. If this window is not running as administrator, Restart as admin may show it, unless its own permissions keep administrators out.",
+ "gui.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 would go with that process could be shorter than the truth. If this window is not running as administrator, Restart as admin may show them, unless their own permissions keep administrators out: {1}.",
"gui.plan.problem.cannotStartLate": "{0} belongs to the load order group {1}, and Windows does not let such an entry start late. Nothing was changed. Automatic starts it at boot with the others.",
"gui.plan.commands": "From a terminal",
@@ -427,6 +430,7 @@
"gui.plan.failure.refused": "{0} would not {1}: {2}",
"gui.plan.failure.stoppedWhileStarting": "{0} did not start - it stopped again with exit code {1}: {2}",
"gui.plan.failure.startedAgain": "The process behind {0} was ended, and Windows started {0} again at once, in process {1}.",
+ "gui.plan.failure.lostSight": "{0} was asked to {1}, and then it could not be read, so where it went is not known: {2}",
"gui.plan.failure.timedOut": "{0} did not finish the {1} while we watched. It may arrive by itself.",
"gui.plan.failure.timedOut.process": "{0} did not finish the {1} while we watched. It may arrive by itself. Process {2} is still holding it.",
"gui.plan.failure.refusedStartType": "The startup type of {0} could not be changed: {1}",
diff --git a/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs b/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs
index c7ca83d8..483a48dd 100644
--- a/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs
+++ b/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs
@@ -64,8 +64,9 @@ private static string Restarting(PlanWarning warning)
}
///
- /// The three refusals. An unreadable consequence with no names is the process itself - whether it is
- /// critical could not be read - so it has a sentence of its own.
+ /// The four refusals - the fourth since 2026-10-27, neighbours the manager did not list (logic report R04).
+ /// An unreadable consequence with no names is the process itself - whether it is critical could not be
+ /// read - so it has a sentence of its own.
///
private static string Aftermath(PlanProblem problem) => problem.Kind switch
{
@@ -82,6 +83,14 @@ private static string Restarting(PlanWarning warning)
? Texts.Of("gui.plan.problem.aftermathUnreadable.one", problem.ServiceName, Listed(problem.Related))
: Texts.Of("gui.plan.problem.aftermathUnreadable.many", problem.ServiceName, Listed(problem.Related)),
+ // No names is the list of names itself refused, logic report R04.
+ PlanProblemKind.NeighboursUnreadable when problem.Related.Count == 0 =>
+ Texts.Of("gui.plan.problem.neighboursUnreadable.list", problem.ServiceName),
+
+ PlanProblemKind.NeighboursUnreadable => problem.Related.Count == 1
+ ? Texts.Of("gui.plan.problem.neighboursUnreadable.one", problem.ServiceName, Listed(problem.Related))
+ : Texts.Of("gui.plan.problem.neighboursUnreadable.many", problem.ServiceName, Listed(problem.Related)),
+
_ => throw new ArgumentOutOfRangeException(
nameof(problem), problem.Kind, EquivalentCommand.Unhandled)
};
diff --git a/src/Bws.Gui/ViewModels/PlanWords.cs b/src/Bws.Gui/ViewModels/PlanWords.cs
index 9f65af80..eae9e8f2 100644
--- a/src/Bws.Gui/ViewModels/PlanWords.cs
+++ b/src/Bws.Gui/ViewModels/PlanWords.cs
@@ -312,6 +312,14 @@ internal static string Describe(StepResult result) =>
{ StartedAgain: true } => Texts.Of(
"gui.plan.failure.startedAgain", result.Step.ServiceName, result.ProcessId.Value),
+ // NOT "WOULD NOT", because the manager took the request - it is the reading after it that was
+ // refused, so where the entry went is not known (logic report R02, 2026-10-26).
+ { LostSight: true } => Texts.Of(
+ "gui.plan.failure.lostSight",
+ result.Step.ServiceName,
+ Word(result.Step.Operation),
+ result.Error ?? string.Empty),
+
_ => Texts.Of(
"gui.plan.failure.refused",
result.Step.ServiceName,
diff --git a/tests/Bws.Architecture.Tests/PlanOnlyGuards.cs b/tests/Bws.Architecture.Tests/PlanOnlyGuards.cs
index 52414955..05b91a98 100644
--- a/tests/Bws.Architecture.Tests/PlanOnlyGuards.cs
+++ b/tests/Bws.Architecture.Tests/PlanOnlyGuards.cs
@@ -1,4 +1,6 @@
using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Bws.Architecture.Tests;
@@ -125,14 +127,7 @@ public void Every_writer_that_is_built_goes_straight_into_a_plan_runner()
foreach (Match match in Regex.Matches(
text, @"new\s+WindowsScmControl\s*\(", RegexOptions.None, Sources.Ceiling))
{
- // The statement around it, which is where a plan runner would have to be built too.
- // Bounded by the punctuation that ends one, so a construction two statements away
- // from a plan is as visible here as one on the other side of the file.
- var opens = text.LastIndexOfAny([';', '{', '}'], match.Index) + 1;
- var closes = text.IndexOfAny([';', '{'], match.Index);
- var statement = text[opens..(closes < 0 ? text.Length : closes)];
-
- if (!statement.Contains("new PlanRunner", StringComparison.Ordinal))
+ if (!Statement(text, match.Index).Contains("new PlanRunner", StringComparison.Ordinal))
{
loose.Add($"{Path.GetFileName(file)} at offset {match.Index}");
}
@@ -147,6 +142,82 @@ public void Every_writer_that_is_built_goes_straight_into_a_plan_runner()
Environment.NewLine + string.Join(Environment.NewLine, loose));
}
+ ///
+ /// AND THE RUNNER THAT WRITER GOES INTO CAN READ THE PROCESS AGAIN, since 2026-10-27 (logic report R05).
+ ///
+ /// A runner built without the reader asks nothing, by the builder's own rule, so leaving the
+ /// argument out compiles, keeps every test of the runner green, and quietly drops the last look at
+ /// whether the process became critical and whether its recovery actions changed before it is ended.
+ ///
+ /// Parsed rather than matched as text, since the review of #94: a reader named in a comment or a
+ /// string beside the runner satisfied the first version, and only a construction among the runner's own
+ /// arguments counts now. The guard above is still a shape on text, and says so.
+ ///
+ [Fact]
+ public void Every_runner_on_the_real_machine_reads_the_process_again_before_ending_it()
+ {
+ List blind =
+ [
+ .. Sources.Shipped().SelectMany(file =>
+ Blind(File.ReadAllText(file)).Select(line => $"{Path.GetFileName(file)} line {line}"))
+ ];
+
+ Assert.True(
+ blind.Count == 0,
+ "A PlanRunner on the real machine is built without a WindowsEndingFactsReader, so it ends a process " +
+ "on what the preview read, however long ago:" +
+ Environment.NewLine + string.Join(Environment.NewLine, blind));
+ }
+
+ /// The guard above against the shapes that fooled its first version, and the one it must pass.
+ [Fact]
+ public void A_reader_named_only_in_a_comment_or_a_string_does_not_count()
+ {
+ const string commented =
+ "class C { object M() => new PlanRunner(new WindowsScmControl(), new SystemClock(), null /* new WindowsEndingFactsReader() */); }";
+ const string quoted =
+ "class C { object M() => new PlanRunner(new WindowsScmControl(), new SystemClock(), Of(\"new WindowsEndingFactsReader()\")); }";
+ const string handed =
+ "class C { object M() => new PlanRunner(new WindowsScmControl(), new SystemClock(), new WindowsEndingFactsReader()); }";
+
+ Assert.Single(Blind(commented));
+ Assert.Single(Blind(quoted));
+ Assert.Empty(Blind(handed));
+ }
+
+ ///
+ /// The lines of every writer built where no reader is built among the arguments of the plan runner it goes
+ /// into - a writer with no runner around it counted too, which the guard before this one names as well.
+ ///
+ private static IEnumerable Blind(string text) =>
+ CSharpSyntaxTree.ParseText(text).GetRoot().DescendantNodes()
+ .OfType()
+ .Where(writer => Named(writer, "WindowsScmControl") && !Reads(writer))
+ .Select(writer => writer.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
+
+ /// Whether the nearest plan runner around this writer builds a reader among its own arguments.
+ private static bool Reads(ObjectCreationExpressionSyntax writer) =>
+ writer.Ancestors().OfType().FirstOrDefault(runner => Named(runner, "PlanRunner"))
+ is { ArgumentList: { } arguments }
+ && arguments.Arguments.Any(argument =>
+ argument.Expression is ObjectCreationExpressionSyntax reader && Named(reader, "WindowsEndingFactsReader"));
+
+ private static bool Named(ObjectCreationExpressionSyntax creation, string name) =>
+ string.Equals(creation.Type.ToString(), name, StringComparison.Ordinal);
+
+ ///
+ /// The statement around a construction, which is where a plan runner and its reader would have to be
+ /// built too. Bounded by the punctuation that ends one, so a construction two statements away from a
+ /// plan is as visible here as one on the other side of the file.
+ ///
+ private static string Statement(string text, int at)
+ {
+ var opens = text.LastIndexOfAny([';', '{', '}'], at) + 1;
+ var closes = text.IndexOfAny([';', '{'], at);
+
+ return text[opens..(closes < 0 ? text.Length : closes)];
+ }
+
private static Dictionary Builders()
{
var counts = new Dictionary(StringComparer.Ordinal);
diff --git a/tests/Bws.Cli.Tests/AftermathSentenceTests.cs b/tests/Bws.Cli.Tests/AftermathSentenceTests.cs
index 1f653f1a..1643bc75 100644
--- a/tests/Bws.Cli.Tests/AftermathSentenceTests.cs
+++ b/tests/Bws.Cli.Tests/AftermathSentenceTests.cs
@@ -49,6 +49,23 @@ public void An_unreadable_consequence_with_no_names_is_about_the_process_itself(
PlanText.Describe(new PlanProblem(PlanProblemKind.AftermathUnreadable, "Spooler", ["Fax"])));
}
+ [Fact]
+ public void Neighbours_the_manager_did_not_list_are_named_and_no_names_is_the_list_refused()
+ {
+ // Logic report R04, 2026-10-27: three shapes - the list of names refused, one withheld, several.
+ Assert.Equal(
+ Texts.Of("cli.plan.problem.neighboursUnreadable.list", "Netlogon", string.Empty),
+ PlanText.Describe(new PlanProblem(PlanProblemKind.NeighboursUnreadable, "Netlogon", [])));
+
+ Assert.Equal(
+ Texts.Of("cli.plan.problem.neighboursUnreadable.one", "Netlogon", "Withheld"),
+ PlanText.Describe(new PlanProblem(PlanProblemKind.NeighboursUnreadable, "Netlogon", ["Withheld"])));
+
+ Assert.Equal(
+ Texts.Of("cli.plan.problem.neighboursUnreadable.many", "Netlogon", "Withheld, Kept"),
+ PlanText.Describe(new PlanProblem(PlanProblemKind.NeighboursUnreadable, "Netlogon", ["Withheld", "Kept"])));
+ }
+
[Fact]
public void A_computer_restart_names_who_carries_it()
{
diff --git a/tests/Bws.Cli.Tests/WatchedStepSentenceTests.cs b/tests/Bws.Cli.Tests/WatchedStepSentenceTests.cs
new file mode 100644
index 00000000..9183e6ae
--- /dev/null
+++ b/tests/Bws.Cli.Tests/WatchedStepSentenceTests.cs
@@ -0,0 +1,47 @@
+using Bws.Core;
+using Bws.Core.Planning;
+
+namespace Bws.Cli.Tests;
+
+///
+/// The two step outcomes that got words of their own on 2026-10-26 (logic report R01 and R02): an entry that
+/// got where the step wanted it with nothing asked, and a request the manager took after which the entry
+/// could not be read. The first is not "done", because nothing here did it - a restart then says, a line
+/// further down, that it never stopped the entry. The second is not "refused", because nothing was refused
+/// but the reading.
+///
+public sealed class WatchedStepSentenceTests
+{
+ [Fact]
+ public void An_arrival_nothing_asked_for_says_so_rather_than_done()
+ {
+ var said = PlanText.Describe(Result(StepOutcome.Succeeded) with { ArrivedUnasked = true });
+
+ Assert.Equal(Texts.Of("cli.run.outcome.arrivedUnasked", "40 ms"), said);
+ }
+
+ [Fact]
+ public void A_request_taken_and_then_lost_from_sight_is_not_called_refused()
+ {
+ var said = PlanText.Describe(Result(StepOutcome.Failed) with
+ {
+ LostSight = true,
+ ErrorCode = 5,
+ Error = "Access is denied."
+ });
+
+ Assert.Equal(Texts.Of("cli.run.outcome.lostSight", "Access is denied.", 5), said);
+ }
+
+ private static StepResult Result(StepOutcome outcome) => new()
+ {
+ Step = new PlanStep("Spooler", "Print Spooler", StepOperation.Stop, StepReason.Requested),
+ Outcome = outcome,
+ SkippedBecause = null,
+ Status = EntryStatus.Stopped,
+ ProcessId = Reading.Absent(),
+ ErrorCode = 0,
+ Error = null,
+ Milliseconds = 40
+ };
+}
diff --git a/tests/Bws.Core.Tests/BulkRunTests.cs b/tests/Bws.Core.Tests/BulkRunTests.cs
index 5f1adc2c..f51b5d69 100644
--- a/tests/Bws.Core.Tests/BulkRunTests.cs
+++ b/tests/Bws.Core.Tests/BulkRunTests.cs
@@ -155,11 +155,12 @@ public void An_interrupted_selection_still_reports_what_it_left_alone()
var plan = Bulk(ActionKind.Stop, ["Spooler", "MRxSmb20"]);
var control = Running("Spooler", "MRxSmb20");
- var run = new BulkRunner(new PlanRunner(control, new FakeClock())).Run(
+ // Pressed once the manager has taken the first stop - before it, nothing is sent at all since
+ // 2026-10-26 (logic report R09).
+ var run = new BulkRunner(new PlanRunner(new Interrupting(control, interruption), new FakeClock())).Run(
plan,
Minute,
- interruption.Token,
- starting: (_, _) => interruption.Cancel());
+ interruption.Token);
Assert.True(run.Cancelled);
diff --git a/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs b/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs
index 6087700a..4929791b 100644
--- a/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs
+++ b/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs
@@ -95,6 +95,37 @@ public Reading ReadRecovery(string serviceName)
: Reading.Present(new RecoveryList([], ADay));
}
+ private Reading> _unlisted = Reading>.Present([]);
+
+ /// The names handed in each time the manager's unlisted entries were asked for, in order.
+ internal List> AskedUnlisted { get; } = [];
+
+ ///
+ /// The manager keeps these and does not list them to this account (logic report R04) - the shape a real
+ /// machine produces under elevation only when a service's own permissions keep administrators out.
+ ///
+ internal FakeEndingFacts Unlisting(params string[] serviceNames)
+ {
+ _unlisted = Reading>.Present(serviceNames);
+ return this;
+ }
+
+ /// The list of names to check could not be read.
+ internal FakeEndingFacts RefusingUnlisted(int errorCode = 5)
+ {
+ _unlisted = Reading>.Denied(errorCode, "Access is denied.");
+ return this;
+ }
+
+ public Reading> ReadUnlisted(IEnumerable listed)
+ {
+ AskedUnlisted.Add([.. listed]);
+
+ // Nobody scripted anything - the listing left nobody out, which is what elevation gives on the
+ // owner's machine.
+ return _unlisted;
+ }
+
/// Every process this was asked about, in order, so a test can see it was asked once.
internal List Asked { get; } = [];
diff --git a/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs b/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs
index 328cc665..15887784 100644
--- a/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs
+++ b/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs
@@ -12,8 +12,36 @@ internal sealed partial class FakeScmControl
{
private readonly Dictionary> _dependents = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary _dependentsRefusedWith = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary> _startTypes = new(StringComparer.OrdinalIgnoreCase);
private int? _statusesRefusedWith;
+ ///
+ /// The start type the manager holds for an entry now - since 2026-10-26, for the run's look before its first
+ /// step (logic report R03). An entry nobody set is Manual, which is what lets every older test go on as it was.
+ ///
+ internal FakeScmControl StartingAs(string serviceName, StartType startType)
+ {
+ _startTypes[serviceName] = Reading.Present(startType);
+ return this;
+ }
+
+ /// The manager will not say how an entry starts.
+ internal FakeScmControl RefusingStartType(string serviceName, int errorCode)
+ {
+ _startTypes[serviceName] = Reading.Denied(errorCode, $"refused with {errorCode}");
+ return this;
+ }
+
+ /// How many times anybody asked how an entry starts.
+ internal int StartTypeReads { get; private set; }
+
+ public Reading ReadStartType(string serviceName)
+ {
+ StartTypeReads++;
+
+ return _startTypes.GetValueOrDefault(serviceName, Reading.Present(StartType.Manual));
+ }
+
/// Entries that depend on this one, whatever they are doing.
internal FakeScmControl DependedOnBy(string serviceName, params string[] dependents)
{
diff --git a/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs b/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs
index a5fdd226..a952a545 100644
--- a/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs
+++ b/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs
@@ -215,6 +215,16 @@ internal FakeScmControl OnItsWay(string serviceName, TimeSpan after, ServiceProg
return this;
}
+ ///
+ /// Where an entry on its way gets to instead, when that is not where its pending state points - a start
+ /// that falls back to Stopped, say. Since 2026-10-26 (logic report R01), after .
+ ///
+ internal FakeScmControl EndingIn(string serviceName, EntryStatus status)
+ {
+ Entry(serviceName).Heading = status;
+ return this;
+ }
+
///
/// An entry whose manager sits on every request for before answering it -
/// the shape measured on Windows Server 2025, where one start took half a minute to come back.
@@ -250,6 +260,17 @@ internal FakeScmControl RefusingReads(string serviceName, int errorCode)
return this;
}
+ ///
+ /// An entry that can be read until the manager takes a request for it, and not after - somebody took the
+ /// right to read its state away while the run went on. Since 2026-10-26, logic report R02: until then
+ /// this double could only refuse every reading of an entry, so a step was refused before it asked.
+ ///
+ internal FakeScmControl RefusingReadsOnceAsked(string serviceName, int errorCode)
+ {
+ Entry(serviceName).ReadRefusedOnceAsked = errorCode;
+ return this;
+ }
+
public ControlAnswer Request(string serviceName, StepOperation operation)
{
Requested.Add(serviceName);
@@ -266,6 +287,7 @@ public ControlAnswer Request(string serviceName, StepOperation operation)
return refused;
}
+ entry.ReadRefusedWith ??= entry.ReadRefusedOnceAsked;
entry.AskedAgain();
entry.Moving = true;
@@ -428,6 +450,9 @@ internal void AskedAgain()
internal int? ReadRefusedWith { get; set; }
+ /// What every reading is refused with from the first request the manager takes - R02.
+ internal int? ReadRefusedOnceAsked { get; set; }
+
internal Arrival? Arrival { get; set; }
/// When the manager was asked to move it, on the ruler of .
diff --git a/tests/Bws.Core.Tests/Fakes/Interrupting.cs b/tests/Bws.Core.Tests/Fakes/Interrupting.cs
new file mode 100644
index 00000000..5d8106fe
--- /dev/null
+++ b/tests/Bws.Core.Tests/Fakes/Interrupting.cs
@@ -0,0 +1,56 @@
+using Bws.Core.Planning;
+
+namespace Bws.Core.Tests.Fakes;
+
+///
+/// A manager during whose work somebody presses Stop - after it takes a request, or while it is being read
+/// for who lives in a process.
+///
+/// Since 2026-10-26 (logic report R09). Until then the tests pressed Stop from the progress call the
+/// runner makes just before a step, and the runner sent that step's request anyway - the very thing R09
+/// closed. Pressed there, a step is held now, so the shape these tests are about - a step already asked for,
+/// which the runner promises to watch to its end - has to be made after the request instead.
+///
+///
+/// Pull the lever when the whole listing is read - just before a process is ended - rather than after the first
+/// request the manager takes.
+///
+internal sealed class Interrupting(
+ FakeScmControl inner,
+ CancellationTokenSource lever,
+ bool whileReadingStatuses = false) : IScmControl
+{
+ private int _requests;
+
+ public ControlAnswer Request(string serviceName, StepOperation operation)
+ {
+ var answer = inner.Request(serviceName, operation);
+
+ if (++_requests == 1 && !whileReadingStatuses)
+ {
+ lever.Cancel();
+ }
+
+ return answer;
+ }
+
+ public ControlAnswer Configure(string serviceName, StartSetting wanted) => inner.Configure(serviceName, wanted);
+
+ public ControlAnswer Terminate(int processId, long? createdAt) => inner.Terminate(processId, createdAt);
+
+ public ControlAnswer Read(string serviceName) => inner.Read(serviceName);
+
+ public Reading> ReadStatuses()
+ {
+ if (whileReadingStatuses)
+ {
+ lever.Cancel();
+ }
+
+ return inner.ReadStatuses();
+ }
+
+ public Reading> ReadDependents(string serviceName) => inner.ReadDependents(serviceName);
+
+ public Reading ReadStartType(string serviceName) => inner.ReadStartType(serviceName);
+}
diff --git a/tests/Bws.Core.Tests/ForcedRunTests.cs b/tests/Bws.Core.Tests/ForcedRunTests.cs
index 2bdbae82..42dbb283 100644
--- a/tests/Bws.Core.Tests/ForcedRunTests.cs
+++ b/tests/Bws.Core.Tests/ForcedRunTests.cs
@@ -221,6 +221,26 @@ public void A_neighbour_found_already_stopped_is_not_handed_back()
Assert.Equal("Spooler", Assert.Single(run.Reversal).ServiceName);
}
+ [Fact]
+ public void A_neighbour_watched_stopping_by_itself_before_the_ending_is_not_handed_back()
+ {
+ // Logic report R01, 2026-10-27. Somebody else was stopping it, so its own step asked nothing and only
+ // watched it arrive - it was not in the process when the process ended, exactly like one found already
+ // stopped. It arrives after the entry's own stop has given up, which is when its step reaches it.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .Reaching("Spooler", new ServiceProgress(EntryStatus.StopPending, 0, TimeSpan.Zero, Held))
+ .OnItsWay("Housemate", TimeSpan.FromSeconds(40), new ServiceProgress(EntryStatus.StopPending, 1, TimeSpan.FromSeconds(10), Held));
+
+ var run = new PlanRunner(control, clock).Run(
+ Forced(Stop("Spooler", StepReason.Requested), Stop("Housemate", StepReason.SharesTheProcess), Ending("Housemate")),
+ TimeSpan.FromSeconds(30),
+ TestContext.Current.CancellationToken);
+
+ Assert.True(run.Results[1].ArrivedUnasked);
+ Assert.Equal("Spooler", Assert.Single(run.Reversal).ServiceName);
+ }
+
private static PlanStep Stop(string name, StepReason reason) => new(name, name, StepOperation.Stop, reason);
private static PlanStep Start(string name) => new(name, name, StepOperation.Start, StepReason.Restore);
diff --git a/tests/Bws.Core.Tests/PlanGraphPropertyTests.cs b/tests/Bws.Core.Tests/PlanGraphPropertyTests.cs
index 7484882b..7fb7e3e1 100644
--- a/tests/Bws.Core.Tests/PlanGraphPropertyTests.cs
+++ b/tests/Bws.Core.Tests/PlanGraphPropertyTests.cs
@@ -139,7 +139,8 @@ private static bool TakesDown(PlanStep step, string name) =>
///
/// The cascade is exactly the running entries the manager named as needing the target, each once,
- /// and none at all where nobody asked for one or the target is not being stopped.
+ /// and none at all where nobody asked for one or the target is not being stopped - or where the ask is
+ /// a startup setting, whose stop never carries one since 2026-10-26 (logic report R08).
///
private static string? CascadeWrong(Graph graph, OperationPlan plan)
{
@@ -157,6 +158,7 @@ private static HashSet Expected(Graph graph, OperationPlan plan)
if (target is null
|| !graph.Ask.IncludeDependents
+ || graph.Ask.Kind == ActionKind.SetStartType
|| !StopsTarget(plan, target)
|| graph.Answers[target.ServiceName] is not { IsPresent: true } answer)
{
diff --git a/tests/Bws.Core.Tests/PlanRunHaltTests.cs b/tests/Bws.Core.Tests/PlanRunHaltTests.cs
index cbd0fb98..fb386067 100644
--- a/tests/Bws.Core.Tests/PlanRunHaltTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunHaltTests.cs
@@ -101,6 +101,100 @@ public void An_interruption_does_not_stop_a_step_putting_something_back_from_wai
Assert.True(run.Cancelled);
}
+ [Fact]
+ public void Stop_pressed_while_a_step_gets_ready_sends_nothing_and_a_restart_then_puts_nothing_back()
+ {
+ // Logic report R09, 2026-10-26. The loop asks between steps, and a step then announces itself and
+ // reads the entry - Stop pressed in between used to let the request go after the reading. Nothing
+ // of the step had changed the machine, so it is held exactly as a step not yet reached.
+ using var interruption = new CancellationTokenSource();
+ var control = new FakeScmControl().At(Name, EntryStatus.Running);
+
+ var run = new PlanRunner(control, new FakeClock()).Run(
+ PlanRunProgressTests.Restarting(),
+ TimeSpan.FromSeconds(60),
+ interruption.Token,
+ starting: (_, _) => interruption.Cancel());
+
+ Assert.Equal(SkipReason.Cancelled, run.Results[0].SkippedBecause);
+ Assert.Equal(SkipReason.NothingToPutBack, run.Results[1].SkippedBecause);
+ Assert.Empty(control.Requested);
+ Assert.True(run.Cancelled);
+ }
+
+ [Fact]
+ public void Stop_pressed_while_an_ending_reads_the_process_ends_nothing()
+ {
+ // The widest of the gaps R09 named: before a process dies, the step reads the whole listing and the
+ // dependants of everything in it, and Stop pressed during that used to be answered by the ending.
+ using var interruption = new CancellationTokenSource();
+ var control = new FakeScmControl();
+
+ var run = new PlanRunner(new Interrupting(control, interruption, whileReadingStatuses: true), new FakeClock())
+ .Run(Ending(), TimeSpan.FromSeconds(60), interruption.Token);
+
+ Assert.Equal(SkipReason.Cancelled, Assert.Single(run.Results).SkippedBecause);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void Stop_pressed_just_before_a_setting_is_written_writes_nothing()
+ {
+ using var interruption = new CancellationTokenSource();
+ var control = new FakeScmControl().At(Name, EntryStatus.Running);
+
+ var run = new PlanRunner(control, new FakeClock()).Run(
+ Setting(),
+ TimeSpan.FromSeconds(60),
+ interruption.Token,
+ starting: (_, _) => interruption.Cancel());
+
+ Assert.Equal(SkipReason.Cancelled, Assert.Single(run.Results).SkippedBecause);
+ Assert.Empty(control.Configured);
+ }
+
+ [Fact]
+ public void Abandoning_just_before_a_step_putting_something_back_starts_nothing()
+ {
+ // The first level promises the steps that give back what earlier ones took - the second promises
+ // nothing, and is now heard after the reading too, not only between steps.
+ using var abandonment = new CancellationTokenSource();
+ var control = new FakeScmControl().At(Name, EntryStatus.Running);
+
+ var run = new PlanRunner(control, new FakeClock()).Run(
+ PlanRunProgressTests.Restarting(),
+ TimeSpan.FromSeconds(60),
+ TestContext.Current.CancellationToken,
+ abandonment.Token,
+ starting: (_, number) =>
+ {
+ if (number == 2)
+ {
+ abandonment.Cancel();
+ }
+ });
+
+ Assert.Equal(StepOutcome.Succeeded, run.Results[0].Outcome);
+ Assert.Equal(SkipReason.Cancelled, run.Results[1].SkippedBecause);
+ Assert.Equal([Name], control.Requested);
+ }
+
+ private static OperationPlan Ending() => new()
+ {
+ Action = new ServiceAction(ActionKind.ForceStop, Name, Immediate: true),
+ Steps = [new PlanStep(Name, Name, StepOperation.Terminate, StepReason.Requested, ProcessId: (int)FakeScmControl.FakeProcess)],
+ Warnings = [],
+ Problems = []
+ };
+
+ private static OperationPlan Setting() => new()
+ {
+ Action = new ServiceAction(ActionKind.SetStartType, Name, To: StartSetting.Disabled),
+ Steps = [new PlanStep(Name, Name, StepOperation.SetStartType, StepReason.Requested, To: StartSetting.Disabled)],
+ Warnings = [],
+ Problems = []
+ };
+
///
/// Time that only moves when somebody waits, and that pulls one of the run's two levers once it has
/// moved far enough - the only way to reach a runner in the middle of a step without a second thread.
diff --git a/tests/Bws.Core.Tests/PlanRunProgressTests.cs b/tests/Bws.Core.Tests/PlanRunProgressTests.cs
index 4296d7a7..ab5b87de 100644
--- a/tests/Bws.Core.Tests/PlanRunProgressTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunProgressTests.cs
@@ -140,6 +140,113 @@ public void A_restart_that_only_watched_an_entry_stopping_and_ran_out_puts_nothi
Assert.Empty(control.Requested);
}
+ [Fact]
+ public void A_restart_that_only_watched_an_entry_stop_by_itself_does_not_start_it()
+ {
+ // Logic report R01, the owner's decision of 2026-10-26 - finding E above, the same watch ARRIVING.
+ // Somebody else is stopping the entry when the step reaches it, so the step asks nothing and watches
+ // it get there. The entry is where the step wanted it, which is what Succeeded says - and until that
+ // day the way back read it as this run's own stop, so the restart started a service somebody else
+ // had just stopped.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromSeconds(5), Pending(1, TimeSpan.FromSeconds(10)));
+
+ var run = Run(control, clock, Restarting());
+
+ Assert.Equal(StepOutcome.Succeeded, run.Results[0].Outcome);
+ Assert.True(run.Results[0].ArrivedUnasked);
+ Assert.Equal(SkipReason.NothingToPutBack, run.Results[1].SkippedBecause);
+ Assert.Empty(control.Requested);
+ Assert.Empty(run.Reversal);
+ }
+
+ [Fact]
+ public void A_stop_that_meets_an_entry_starting_which_falls_back_to_stopped_puts_nothing_back()
+ {
+ // The other road to an arrival nobody asked for: the entry was heading the other way - somebody
+ // else was starting it - and it stopped by itself. The step watched it leave StartPending, found it
+ // where the step wanted it, and asked nothing.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .OnItsWay(
+ Name,
+ TimeSpan.FromSeconds(5),
+ new ServiceProgress(EntryStatus.StartPending, 1, TimeSpan.FromSeconds(10), FakeScmControl.FakeProcess))
+ .EndingIn(Name, EntryStatus.Stopped);
+
+ var run = Run(control, clock, Restarting());
+
+ Assert.True(run.Results[0].ArrivedUnasked);
+ Assert.Equal(SkipReason.NothingToPutBack, run.Results[1].SkippedBecause);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void A_start_putting_back_an_entry_somebody_else_was_already_starting_leaves_no_way_back()
+ {
+ // The other half of R01: an arrival nobody asked for still says where the entry ended up. The
+ // restart's stop was this run's, the start found the entry already on its way up and only watched -
+ // so the entry ended where it began, and "start it" would be a line about a service that is running.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Reaching(
+ Name,
+ Stopped,
+ new ServiceProgress(EntryStatus.StartPending, 1, TimeSpan.FromSeconds(10), FakeScmControl.FakeProcess),
+ new ServiceProgress(EntryStatus.Running, 0, TimeSpan.Zero, FakeScmControl.FakeProcess));
+
+ var run = Run(control, clock, Restarting());
+
+ Assert.False(run.Results[0].ArrivedUnasked);
+ Assert.True(run.Results[1].ArrivedUnasked);
+ Assert.Equal([Name], control.Requested);
+ Assert.Empty(run.Reversal);
+ }
+
+ [Fact]
+ public void A_stop_the_manager_took_and_then_lost_from_sight_is_still_this_run_s_move()
+ {
+ // Logic report R02, 2026-10-26. The manager took the stop, and then the entry could not be read.
+ // Until that day that was a plain failure, which the way back reads as "nothing moved" - so the
+ // restart did not try to put the entry back and the way back named nothing. It may well have
+ // stopped, the same reason a step that ran out of time counts.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .RefusingReadsOnceAsked(Name, AccessDenied);
+
+ var run = Run(control, clock, Restarting());
+
+ Assert.Equal(StepOutcome.Failed, run.Results[0].Outcome);
+ Assert.True(run.Results[0].LostSight);
+ Assert.Equal(AccessDenied, run.Results[0].ErrorCode);
+
+ // Tried rather than skipped - and refused, because the entry still cannot be read, which says so.
+ Assert.Equal(StepOutcome.Failed, run.Results[1].Outcome);
+ Assert.False(run.Results[1].LostSight);
+ Assert.Equal(StepOperation.Start, Assert.Single(run.Reversal).Operation);
+ }
+
+ [Fact]
+ public void A_refusal_before_anything_was_asked_is_not_lost_sight()
+ {
+ // The line the fact draws: nothing was taken, so nothing is out of sight.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .RefusingReads(Name, AccessDenied);
+
+ var result = Assert.Single(Run(control, clock, Stopping()).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.False(result.LostSight);
+ Assert.Empty(control.Requested);
+ }
+
+ private const int AccessDenied = 5;
+
[Fact]
public void The_time_the_manager_sits_on_a_request_is_what_outruns_the_limit()
{
diff --git a/tests/Bws.Core.Tests/PlanRunPropertyTests.cs b/tests/Bws.Core.Tests/PlanRunPropertyTests.cs
index 70d9b230..3303f4a7 100644
--- a/tests/Bws.Core.Tests/PlanRunPropertyTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunPropertyTests.cs
@@ -49,6 +49,12 @@ public void A_run_asks_only_what_the_preview_showed_and_puts_back_what_it_took_d
/// trouble table turns this into a replay of something else, silently - which is why the shape is
/// also written out by hand in PlanRunProgressTests, where no generator stands between it and the
/// code.
+ ///
+ /// Checked when the table grew dice 9 and 10 on 2026-10-26 (logic report R02, R03), by printing
+ /// what the seed draws: dice 9 6 6 14 8 0 2. The 9, behaving until then, now falls on Alpha - stopped and
+ /// in no step of the plan, so never asked and never read after a request, which is the only thing that
+ /// trouble changes. The housemates on dice 6 are still watched and given up on, and nothing is put back.
+ /// Grow the table again and this has to be printed again.
///
[Fact]
public void The_run_the_first_minute_long_search_found_puts_back_nothing_it_never_asked_to_stop()
@@ -191,7 +197,13 @@ private static void Troubled(FakeScmControl control, ScmEntry entry, int die)
(control, name, _) => control.Reaching(
name,
new ServiceProgress(EntryStatus.StartPending, 1, TimeSpan.FromSeconds(2), 0),
- new ServiceProgress(EntryStatus.Stopped, 0, TimeSpan.Zero, 0, ExitCode: 1067))
+ new ServiceProgress(EntryStatus.Stopped, 0, TimeSpan.Zero, 0, ExitCode: 1067)),
+
+ // Lost from sight: readable until the manager takes a request for it, refused after (R02, 2026-10-26).
+ (control, name, _) => control.RefusingReadsOnceAsked(name, Entries.AccessDenied),
+
+ // Set to Disabled since the preview, so a restart refuses before its first step (R03, 2026-10-26).
+ (control, name, _) => control.StartingAs(name, StartType.Disabled)
];
private static ServiceProgress Pending(EntryStatus settled) =>
@@ -271,14 +283,13 @@ private static bool AfterStop(Ran ran, int position) =>
position > ran.StoppedAt && ran.Step(position).Reason != StepReason.Restore;
///
- /// Nothing is put back that the run did not take down - by sending a stop or an ending, or by
- /// watching it arrive at Stopped under a step that asked for exactly that.
+ /// Nothing is put back that the run did not take down by SENDING a stop or an ending.
///
- /// The second half was learnt from the first minute-long search, 2026-10-17 (seed
- /// 0l4gUzq4NGe5, 7.8 million draws). A housemate already on its way down when its step came was
- /// watched, arrived, and the step succeeded with nothing sent - which PlanRunner.Waiting says is
- /// meant: success is the entry reaching the state the step asked for, not this tool sending it
- /// there. The preview had shown it stopped and started again, so starting it is the preview kept.
+ /// A second half stood here from 2026-10-17 to 2026-10-26 and it is gone on the owner's decision of
+ /// that day (logic report R01). The first minute-long search (seed 0l4gUzq4NGe5) found a housemate
+ /// already on its way down, watched, arrived, its step succeeded with nothing sent - and this oracle was
+ /// widened to call that "taken down", so starting it again counted as the preview kept. It is somebody
+ /// else's stop: the step is marked ArrivedUnasked and nothing puts the entry back.
///
private static string? PutBackUntaken(Ran ran)
{
@@ -290,7 +301,7 @@ private static bool AfterStop(Ran ran, int position) =>
private static bool TakenDownBefore(Ran ran, int at) =>
ran.Positions.Any(before => before < at
- && (ran.Wrote(before) || ran.Result(before).Outcome == StepOutcome.Succeeded)
+ && ran.Wrote(before)
&& TakesDown(ran.Step(before), ran.Step(at).ServiceName));
private static bool TakesDown(PlanStep step, string name) =>
@@ -317,10 +328,11 @@ private static bool Owed(Ran ran, int at) =>
&& !(at >= ran.AbandonedAt)
&& ran.Positions.Any(before => before < at && StoppedHere(ran, before, ran.Step(at).ServiceName));
+ /// A stop this run sent and saw arrive - an arrival it only watched is somebody else's (R01).
private static bool StoppedHere(Ran ran, int position, string name) =>
ran.Step(position).Operation == StepOperation.Stop
&& string.Equals(ran.Step(position).ServiceName, name, StringComparison.OrdinalIgnoreCase)
- && ran.Result(position).Outcome == StepOutcome.Succeeded;
+ && ran.Result(position) is { Outcome: StepOutcome.Succeeded, ArrivedUnasked: false };
private static bool Forward(PlanStep step) => step.Reason is StepReason.Requested or StepReason.Cascade;
@@ -378,6 +390,8 @@ public ControlAnswer Terminate(int processId, long? createdAt)
public Reading> ReadStatuses() => inner.ReadStatuses();
public Reading> ReadDependents(string serviceName) => inner.ReadDependents(serviceName);
+
+ public Reading ReadStartType(string serviceName) => inner.ReadStartType(serviceName);
}
/// A plan, its run, and what the machine was sent - with positions counted from one, as the runner counts them.
diff --git a/tests/Bws.Core.Tests/PlanRunnerTests.cs b/tests/Bws.Core.Tests/PlanRunnerTests.cs
index e79ec000..548f3198 100644
--- a/tests/Bws.Core.Tests/PlanRunnerTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunnerTests.cs
@@ -244,15 +244,17 @@ public void Interrupting_a_restart_does_not_leave_the_service_stopped()
// classified as forward progress rather than as giving something back. A restart
// that leaves the thing it was restarting switched off is the worst outcome this
// command has.
+ //
+ // Pressed once the manager has taken the stop - the moment the step is watched. Pressed before
+ // the request, nothing is stopped at all since 2026-10-26 (logic report R09, PlanRunHaltTests).
using var interruption = new CancellationTokenSource();
var control = Running("MRxSmb20");
- var run = new PlanRunner(control, new FakeClock()).Run(
+ var run = new PlanRunner(new Interrupting(control, interruption), new FakeClock()).Run(
Plan(ActionKind.Restart, "MRxSmb20", dependents: false),
Minute,
- interruption.Token,
- starting: (_, _) => interruption.Cancel());
+ interruption.Token);
Assert.True(run.Cancelled);
Assert.Equal(StepOutcome.Succeeded, Outcome(run, "MRxSmb20", StepOperation.Stop));
@@ -287,22 +289,15 @@ public void Interrupting_a_run_stops_the_forward_steps_and_still_gives_back_what
{
// Somebody who presses Ctrl+C half way through a restart has to be left with a
// machine, not with three stopped services and no message. The forward path stops
- // and the restores still run.
+ // and the restores still run. Pressed once the manager has taken the first stop.
using var interruption = new CancellationTokenSource();
var control = Running("SessionEnv", "Netlogon", "LanmanWorkstation", "MRxSmb20");
- var run = new PlanRunner(control, new FakeClock()).Run(
+ var run = new PlanRunner(new Interrupting(control, interruption), new FakeClock()).Run(
Plan(ActionKind.Restart, "MRxSmb20"),
Minute,
- interruption.Token,
- starting: (_, number) =>
- {
- if (number == 1)
- {
- interruption.Cancel();
- }
- });
+ interruption.Token);
Assert.True(run.Cancelled);
@@ -348,28 +343,28 @@ public void Asking_twice_leaves_things_as_they_are_and_still_reports_what_was_do
// reaches for is killing the process - and a killed process says nothing at all
// about the half of a cascade it left switched off. Observed on a virtual machine
// on 2026-08-01, where exactly that left a service stopped with no report.
+ //
+ // Both asks land once the manager has taken the first stop, through the linked source. Pressed
+ // before the request, as this test did until 2026-10-27, nothing was sent at all since R09 - and it
+ // stayed green while no longer abandoning anything the run had done.
using var interruption = new CancellationTokenSource();
- using var abandonment = new CancellationTokenSource();
+ using var abandonment = CancellationTokenSource.CreateLinkedTokenSource(interruption.Token);
var control = Running("SessionEnv", "Netlogon", "LanmanWorkstation", "MRxSmb20");
- var run = new PlanRunner(control, new FakeClock()).Run(
+ var run = new PlanRunner(new Interrupting(control, interruption), new FakeClock()).Run(
Plan(ActionKind.Restart, "MRxSmb20"),
Minute,
interruption.Token,
- abandonment.Token,
- starting: (_, number) =>
- {
- if (number == 1)
- {
- interruption.Cancel();
- abandonment.Cancel();
- }
- });
+ abandonment.Token);
Assert.True(run.Cancelled);
Assert.False(run.Completed);
+ // The first stop was sent and taken, and nothing else was sent - not even the start putting it back.
+ Assert.Equal(StepOutcome.Succeeded, run.Results[0].Outcome);
+ Assert.Equal([run.Results[0].Step.ServiceName], control.Requested);
+
// Everything after the first step, the steps that put things back included.
Assert.All(
run.Results.Skip(1),
diff --git a/tests/Bws.Core.Tests/SecondLookTests.cs b/tests/Bws.Core.Tests/SecondLookTests.cs
new file mode 100644
index 00000000..10c13971
--- /dev/null
+++ b/tests/Bws.Core.Tests/SecondLookTests.cs
@@ -0,0 +1,280 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+using static Bws.Core.Tests.Fakes.DependencyChain;
+
+namespace Bws.Core.Tests;
+
+///
+/// The looks a run takes at the machine before the first thing it cannot undo - whether what a restart takes
+/// down can come back (logic report R03), and what ending a process sets off (R05). Both on the owner's
+/// decisions of 2026-10-26.
+///
+/// Every test here was a plan carried out on an answer the preview had and the machine no longer gave.
+/// A restart of an entry set to Disabled after the window's last full reading stopped it and could not start
+/// it, and an ending whose recovery list became "restart the computer" after the preview went ahead.
+///
+public sealed class SecondLookTests
+{
+ private const string Name = "Spooler";
+
+ private const int Netlogon = 7777;
+
+ [Fact]
+ public void A_restart_of_an_entry_set_to_Disabled_after_the_preview_stops_nothing()
+ {
+ var control = new FakeScmControl().At(Name, EntryStatus.Running).StartingAs(Name, StartType.Disabled);
+
+ var run = Run(control, PlanRunProgressTests.Restarting());
+
+ Assert.Equal(StepOutcome.Failed, run.Results[0].Outcome);
+ Assert.Equal(0, run.Results[0].ErrorCode);
+ Assert.Contains(Name, run.Results[0].Error, StringComparison.Ordinal);
+ Assert.Contains("Disabled", run.Results[0].Error, StringComparison.Ordinal);
+ Assert.Equal(SkipReason.NothingToPutBack, run.Results[1].SkippedBecause);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void A_dependant_set_to_Disabled_holds_the_whole_restart()
+ {
+ // The dependant is the first thing a restart takes down, so it is what the refusal lands on - and
+ // the entry asked about is never stopped either, because nothing of the plan may run without the way
+ // back it promised.
+ var control = new FakeScmControl()
+ .At("Netlogon", EntryStatus.Running)
+ .At(Name, EntryStatus.Running)
+ .StartingAs("Netlogon", StartType.Disabled);
+
+ var run = Run(control, Restart(Name, "Netlogon"));
+
+ Assert.Contains("Netlogon", run.Results[0].Error, StringComparison.Ordinal);
+ Assert.Equal(SkipReason.EarlierStepFailed, run.Results[1].SkippedBecause);
+ Assert.All(run.Results.Skip(2), result => Assert.Equal(SkipReason.NothingToPutBack, result.SkippedBecause));
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void A_forced_restart_refused_for_a_disabled_neighbour_ends_no_process()
+ {
+ // The steps standing behind a failure - the neighbours and the ending - run after an ordinary one,
+ // because they exist for the stop that did not work. A refusal of the whole plan is not that, and
+ // the first draft of this check let them go on and end the process.
+ var control = new FakeScmControl()
+ .At(Name, EntryStatus.Running)
+ .At("Housemate", EntryStatus.Running)
+ .StartingAs("Housemate", StartType.Disabled);
+
+ var run = Run(control, ForcedRestart("Housemate"));
+
+ Assert.Contains("Housemate", run.Results[0].Error, StringComparison.Ordinal);
+ Assert.Equal(SkipReason.EarlierStepFailed, run.Results[1].SkippedBecause);
+ Assert.Equal(SkipReason.EarlierStepFailed, run.Results[2].SkippedBecause);
+ Assert.All(run.Results.Skip(3), result => Assert.Equal(SkipReason.NothingToPutBack, result.SkippedBecause));
+ Assert.Empty(control.Requested);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void A_start_type_nobody_could_read_holds_nothing_back()
+ {
+ // Missing information is not a reason to refuse - the builder's line, held here too.
+ var control = new FakeScmControl().At(Name, EntryStatus.Running).RefusingStartType(Name, 5);
+
+ var run = Run(control, PlanRunProgressTests.Restarting());
+
+ Assert.All(run.Results, result => Assert.Equal(StepOutcome.Succeeded, result.Outcome));
+ Assert.Equal([Name, Name], control.Requested);
+ }
+
+ [Fact]
+ public void A_plan_that_puts_nothing_back_asks_no_start_type()
+ {
+ var control = new FakeScmControl().At(Name, EntryStatus.Running);
+
+ Run(control, PlanRunProgressTests.Stopping());
+
+ Assert.Equal(0, control.StartTypeReads);
+ }
+
+ [Fact]
+ public void An_ending_whose_recovery_list_is_what_the_preview_read_goes_ahead()
+ {
+ // Two lists with the same items are two objects - the comparison is by value, not by reference.
+ var facts = new FakeEndingFacts().Recovering(Name, RecoveryAction.RestartService, RecoveryAction.Nothing);
+ var control = new FakeScmControl();
+
+ var run = Run(control, Ending(Frozen(Name, RecoveryAction.RestartService, RecoveryAction.Nothing)), facts);
+
+ Assert.Equal(StepOutcome.Succeeded, Assert.Single(run.Results).Outcome);
+ Assert.Single(control.Ended);
+ }
+
+ [Theory]
+ [InlineData(RecoveryAction.RestartComputer)]
+ [InlineData(RecoveryAction.RunProgram)]
+ [InlineData(RecoveryAction.Nothing)]
+ public void An_ending_whose_recovery_list_changed_after_the_preview_ends_nothing(RecoveryAction now)
+ {
+ // A restart of the computer is the case the report was about, and it is not the only one: a list
+ // that gained a program, or lost its restart, makes the preview's sentences untrue as well.
+ var facts = new FakeEndingFacts().Recovering(Name, now);
+ var control = new FakeScmControl();
+
+ var result = Assert.Single(Run(control, Ending(Frozen(Name, RecoveryAction.RestartService)), facts).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.Contains(Name, result.Error, StringComparison.Ordinal);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void A_recovery_list_the_manager_refuses_just_before_is_said_unread_with_its_number()
+ {
+ // Not "changed": the list could not be read, and the manager's own number comes with it, as for the
+ // criticality above (the review of #94).
+ var facts = new FakeEndingFacts().RefusingRecovery(Name, errorCode: 5);
+ var control = new FakeScmControl();
+
+ var result = Assert.Single(Run(control, Ending(Frozen(Name, RecoveryAction.RestartService)), facts).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.Equal(5, result.ErrorCode);
+ Assert.Contains("could not be read", result.Error, StringComparison.Ordinal);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void A_neighbour_whose_recovery_list_changed_holds_the_ending_and_is_named()
+ {
+ var facts = new FakeEndingFacts().Recovering("Housemate", RecoveryAction.RestartComputer);
+ var control = new FakeScmControl().At("Housemate", EntryStatus.Running);
+ var step = Ending([Frozen(Name), Frozen("Housemate")], "Housemate");
+
+ var result = Assert.Single(Run(control, step, facts).Results);
+
+ Assert.Contains("Housemate", result.Error, StringComparison.Ordinal);
+ Assert.DoesNotContain(Name + ",", result.Error, StringComparison.Ordinal);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void A_process_that_became_critical_after_the_preview_is_not_ended()
+ {
+ var facts = new FakeEndingFacts().Critical((int)FakeScmControl.FakeProcess);
+ var control = new FakeScmControl();
+
+ var result = Assert.Single(Run(control, Ending(Frozen(Name)), facts).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void A_process_whose_criticality_cannot_be_read_just_before_is_not_ended()
+ {
+ var facts = new FakeEndingFacts().CriticalUnreadable((int)FakeScmControl.FakeProcess);
+ var control = new FakeScmControl();
+
+ var result = Assert.Single(Run(control, Ending(Frozen(Name)), facts).Results);
+
+ Assert.Equal(5, result.ErrorCode);
+ Assert.Empty(control.Ended);
+ }
+
+ [Fact]
+ public void A_runner_with_nobody_to_ask_ends_on_what_the_earlier_checks_found()
+ {
+ // The builder's rule, nothing read is nothing said: the same ending, given no reader, goes ahead.
+ var control = new FakeScmControl();
+
+ new PlanRunner(control, new FakeClock()).Run(Ending(Frozen(Name, RecoveryAction.RestartService)), TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ Assert.Single(control.Ended);
+ }
+
+ [Fact]
+ public void The_builder_freezes_what_it_read_and_the_same_machine_lets_the_run_through()
+ {
+ // The two halves together, over the measured chain: what the preview read travels on the ending
+ // step, and a machine that did not change between the two moments ends the process.
+ var facts = new FakeEndingFacts().Recovering("Netlogon", RecoveryAction.RestartService);
+ var catalog = Housed(("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 6666), ("Netlogon", Netlogon));
+ var plan = new PlanBuilder(catalog.ReadAll(), catalog, facts)
+ .Build(new ServiceAction(ActionKind.ForceStop, "Netlogon", Immediate: true));
+
+ var ending = Assert.Single(plan.Steps);
+ Assert.Equal("Netlogon", Assert.Single(ending.Recovery!).ServiceName);
+
+ var control = new FakeScmControl().RunningIn("Netlogon", Netlogon);
+ var run = new PlanRunner(control, new FakeClock(), facts).Run(plan, TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ Assert.Equal(StepOutcome.Succeeded, Assert.Single(run.Results).Outcome);
+ Assert.Equal([Netlogon], control.Ended);
+ }
+
+ private static RecoveryReading Frozen(string serviceName, params RecoveryAction[] actions) =>
+ new(serviceName, Reading.Present(new RecoveryList(
+ [.. actions.Select(action => new RecoveryItem(action, TimeSpan.Zero))], FakeEndingFacts.ADay)));
+
+ private static OperationPlan Ending(RecoveryReading frozen) => Ending([frozen]);
+
+ private static OperationPlan Ending(IReadOnlyList frozen, params string[] housemates) => new()
+ {
+ Action = new ServiceAction(ActionKind.ForceStop, Name, Immediate: true),
+ Steps =
+ [
+ new PlanStep(
+ Name,
+ Name,
+ StepOperation.Terminate,
+ StepReason.Requested,
+ ProcessId: (int)FakeScmControl.FakeProcess,
+ TakesWithIt: housemates,
+ Recovery: frozen)
+ ],
+ Warnings = [],
+ Problems = []
+ };
+
+ /// A forced restart with its polite stop, the neighbours, the ending and the way back, by hand.
+ private static OperationPlan ForcedRestart(string housemate) => new()
+ {
+ Action = new ServiceAction(ActionKind.ForceRestart, Name),
+ Steps =
+ [
+ new PlanStep(Name, Name, StepOperation.Stop, StepReason.Requested),
+ new PlanStep(housemate, housemate, StepOperation.Stop, StepReason.SharesTheProcess),
+ new PlanStep(
+ Name,
+ Name,
+ StepOperation.Terminate,
+ StepReason.Escalation,
+ ProcessId: (int)FakeScmControl.FakeProcess,
+ TakesWithIt: [housemate],
+ Recovery: [Frozen(Name), Frozen(housemate)]),
+ new PlanStep(Name, Name, StepOperation.Start, StepReason.Restore),
+ new PlanStep(housemate, housemate, StepOperation.Start, StepReason.Restore)
+ ],
+ Warnings = [],
+ Problems = []
+ };
+
+ /// A restart taking the dependants down first and putting them back last, written out by hand.
+ private static OperationPlan Restart(string serviceName, params string[] dependants) => new()
+ {
+ Action = new ServiceAction(ActionKind.Restart, serviceName, IncludeDependents: true),
+ Steps =
+ [
+ .. dependants.Select(name => new PlanStep(name, name, StepOperation.Stop, StepReason.Cascade)),
+ new PlanStep(serviceName, serviceName, StepOperation.Stop, StepReason.Requested),
+ new PlanStep(serviceName, serviceName, StepOperation.Start, StepReason.Restore),
+ .. Enumerable.Reverse(dependants).Select(name => new PlanStep(name, name, StepOperation.Start, StepReason.Restore))
+ ],
+ Warnings = [],
+ Problems = []
+ };
+
+ private static PlanRun Run(FakeScmControl control, OperationPlan plan, FakeEndingFacts? facts = null) =>
+ new PlanRunner(control, new FakeClock(), facts).Run(plan, TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+}
diff --git a/tests/Bws.Core.Tests/StartSettingTests.cs b/tests/Bws.Core.Tests/StartSettingTests.cs
index 0fdc3af6..f14729d4 100644
--- a/tests/Bws.Core.Tests/StartSettingTests.cs
+++ b/tests/Bws.Core.Tests/StartSettingTests.cs
@@ -203,15 +203,39 @@ public void Taking_the_offer_puts_the_stop_after_the_setting_and_drops_the_sente
warning => warning.Kind is PlanWarningKind.KeepsRunning or PlanWarningKind.ReturnsAfterReboot);
}
- /// An entry that is already stopped gets no stop step from the offer.
+ ///
+ /// AN ENTRY READ AS STOPPED KEEPS THE STOP, SINCE 2026-10-26 - logic report R03, the owner's decision of
+ /// that day. Until then it got no stop step, and one started between the preview and the press was set
+ /// to Disabled and left running under a plan that said "and stop it". The step is free when it is not
+ /// needed: the runner finds the entry already there.
+ ///
+ [Fact]
+ public void The_offer_keeps_its_stop_for_an_entry_read_as_stopped()
+ {
+ var plan = Plan(
+ Ask("Spooler", StartSetting.Disabled, alsoStop: true),
+ Specimens.Ordinary with { Status = EntryStatus.Stopped });
+
+ Assert.Equal([StepOperation.SetStartType, StepOperation.Stop], plan.Steps.Select(step => step.Operation));
+
+ var still = Run(new FakeScmControl().At("Spooler", EntryStatus.Stopped), plan);
+
+ Assert.Equal(SkipReason.AlreadyThere, still.Results[1].SkippedBecause);
+ }
+
+ /// The shape R03 was about: read as stopped, started before the press - and stopped by the plan.
[Fact]
- public void The_offer_adds_no_stop_to_an_entry_already_stopped()
+ public void An_entry_started_after_the_preview_of_disable_and_stop_is_stopped()
{
var plan = Plan(
Ask("Spooler", StartSetting.Disabled, alsoStop: true),
Specimens.Ordinary with { Status = EntryStatus.Stopped });
+ var control = new FakeScmControl().At("Spooler", EntryStatus.Running);
+
+ var run = Run(control, plan);
- Assert.Equal(StepOperation.SetStartType, Assert.Single(plan.Steps).Operation);
+ Assert.All(run.Results, result => Assert.Equal(StepOutcome.Succeeded, result.Outcome));
+ Assert.Equal(["Spooler"], control.Requested);
}
///
@@ -247,6 +271,31 @@ public void A_stop_riding_on_a_setting_names_what_is_in_its_way()
Assert.DoesNotContain(plan.Steps, step => step.ServiceName == "Fax");
}
+ ///
+ /// THE CASCADE WORD DOES NOT REACH A SETTING EVEN WHEN IT CARRIES A STOP - logic report R08, 2026-10-26.
+ /// The command line refuses it, the line handed back leaves it out and the window's hand-over refuses
+ /// it, and until that day this builder alone planned the dependants' stops - a preview doing more than
+ /// the command it printed. They are named in the way instead, as without the word.
+ ///
+ [Fact]
+ public void A_setting_that_stops_takes_no_dependants_down_whatever_it_carries()
+ {
+ var catalog = new FakeScmCatalog(new List(Specimens.All)
+ {
+ Specimens.Ordinary with { ServiceName = "Fax", DisplayName = "Faks" }
+ }).DependedOnBy("Spooler", "Fax");
+
+ var ask = new ServiceAction(
+ ActionKind.SetStartType, "Spooler", IncludeDependents: true, To: StartSetting.Disabled, AlsoStop: true);
+ var plan = new PlanBuilder(catalog.ReadAll(), catalog).Build(ask);
+
+ Assert.DoesNotContain(plan.Steps, step => step.ServiceName == "Fax");
+ Assert.Contains(
+ plan.Warnings,
+ warning => warning.Kind == PlanWarningKind.DependentsInTheWay && warning.Related.Contains("Fax"));
+ Assert.Equal("bws start-type Spooler disabled --stop", EquivalentCommand.For(ask));
+ }
+
///
/// A selection whose setting carries a stop goes dependents first, the way a plain stop of the
/// same selection does - LanmanWorkstation depends on MRxSmb20, so it stops first, whatever
@@ -309,6 +358,8 @@ private static OperationPlan Plan(ServiceAction action, ScmEntry? instead = null
return new PlanBuilder(catalog.ReadAll(), catalog).Build(action);
}
- private static PlanRun Run(FakeScmControl control, ServiceAction action) =>
- new PlanRunner(control, new FakeClock()).Run(Plan(action), TimeSpan.FromSeconds(5));
+ private static PlanRun Run(FakeScmControl control, ServiceAction action) => Run(control, Plan(action));
+
+ private static PlanRun Run(FakeScmControl control, OperationPlan plan) =>
+ new PlanRunner(control, new FakeClock()).Run(plan, TimeSpan.FromSeconds(5));
}
diff --git a/tests/Bws.Core.Tests/UnlistedNeighbourTests.cs b/tests/Bws.Core.Tests/UnlistedNeighbourTests.cs
new file mode 100644
index 00000000..e28a114c
--- /dev/null
+++ b/tests/Bws.Core.Tests/UnlistedNeighbourTests.cs
@@ -0,0 +1,131 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+using static Bws.Core.Tests.Fakes.DependencyChain;
+
+namespace Bws.Core.Tests;
+
+///
+/// A plan that ends a shared process, on a machine whose manager keeps some entries out of the listing - logic
+/// report R04, the owner's decision of 2026-10-26 to detect what the manager hides.
+///
+/// The shape a real machine produces without an error anywhere: Microsoft's enumeration silently leaves
+/// out every entry the caller may not query, and on the owner's machine three entries went missing that way
+/// under a restricted token (backlog 493). Until this day the preview of an ending named the neighbours the
+/// listing showed and nobody else.
+///
+/// The machine is the measured chain with Netlogon and SessionEnv sharing process 7777, so the plan
+/// itself is one the tool already builds.
+///
+public sealed class UnlistedNeighbourTests
+{
+ private const int Netlogon = 7777;
+
+ [Fact]
+ public void An_entry_the_manager_did_not_list_is_named_and_nothing_is_ended()
+ {
+ var plan = Forced(Shared(), new FakeEndingFacts().Unlisting("Withheld"));
+
+ var problem = Assert.Single(plan.Problems);
+ Assert.Equal(PlanProblemKind.NeighboursUnreadable, problem.Kind);
+ Assert.Equal("Netlogon", problem.ServiceName);
+ Assert.Equal(["Withheld"], problem.Related);
+ Assert.Empty(plan.Steps);
+ }
+
+ [Fact]
+ public void A_list_of_names_nobody_could_read_is_a_refusal_naming_nobody()
+ {
+ var plan = Forced(Shared(), new FakeEndingFacts().RefusingUnlisted());
+
+ var problem = Assert.Single(plan.Problems);
+ Assert.Equal(PlanProblemKind.NeighboursUnreadable, problem.Kind);
+ Assert.Empty(problem.Related);
+ }
+
+ [Fact]
+ public void The_question_carries_every_name_of_the_listing_the_plan_was_built_from()
+ {
+ var catalog = Shared();
+ var facts = new FakeEndingFacts();
+
+ var plan = Forced(catalog, facts);
+
+ Assert.True(plan.IsRunnable);
+ Assert.Equal(catalog.ReadAll().Select(entry => entry.ServiceName), Assert.Single(facts.AskedUnlisted));
+ }
+
+ [Fact]
+ public void An_entry_with_a_process_of_its_own_has_no_neighbours_to_leave_out_and_is_not_asked()
+ {
+ var facts = new FakeEndingFacts().Unlisting("Withheld");
+
+ var plan = Forced(Housed(("SessionEnv", Netlogon), ("Netlogon", Netlogon)), facts);
+
+ Assert.Empty(plan.Problems);
+ Assert.Empty(facts.AskedUnlisted);
+ }
+
+ [Fact]
+ public void A_critical_process_is_refused_before_the_manager_is_asked_who_it_left_out()
+ {
+ var facts = new FakeEndingFacts().Critical(Netlogon).Unlisting("Withheld");
+
+ var plan = Forced(Shared(), facts);
+
+ Assert.Equal(PlanProblemKind.ProcessIsCritical, Assert.Single(plan.Problems).Kind);
+ Assert.Empty(facts.AskedUnlisted);
+ }
+
+ [Fact]
+ public void A_plan_with_nobody_to_ask_is_built_as_it_always_was()
+ {
+ var catalog = Shared();
+
+ var plan = new PlanBuilder(catalog.ReadAll(), catalog).Build(new ServiceAction(ActionKind.ForceStop, "Netlogon"));
+
+ Assert.True(plan.IsRunnable);
+ }
+
+ [Theory]
+ [InlineData(0x20, true)]
+ [InlineData(0x60, true)]
+ [InlineData(0x10, false)]
+ [InlineData(0x50, false)]
+ [InlineData(0x1, false)]
+ public void Only_a_type_with_the_shared_bit_can_live_beside_another(int type, bool shares)
+ {
+ // The real reader's decision on the registry's Type value (the review of #94): 0x60 is a per-user
+ // template sharing a process, 0x50 one with its own, 0x1 a driver.
+ Assert.Equal(shares, WindowsEndingFactsReader.CouldShare(type));
+ }
+
+ [Fact]
+ public void A_key_with_no_type_is_no_entry_and_a_type_of_another_shape_is_asked_about()
+ {
+ Assert.False(WindowsEndingFactsReader.CouldShare(null));
+ Assert.True(WindowsEndingFactsReader.CouldShare("0x20"));
+ }
+
+ [Theory]
+ [InlineData(5, true)]
+ [InlineData(1060, false)]
+ [InlineData(123, false)]
+ [InlineData(6, true)]
+ public void Only_a_name_the_manager_holds_and_refuses_is_withheld(int code, bool withheld)
+ {
+ // 1060 and 123 say the name is no entry. Any other refusal - 5 above all - is an entry this account is
+ // not shown, the shape the enumeration leaves out without a word.
+ Assert.Equal(withheld, WindowsEndingFactsReader.Withholds(code));
+ }
+
+ private static OperationPlan Forced(FakeScmCatalog catalog, FakeEndingFacts facts) =>
+ new PlanBuilder(catalog.ReadAll(), catalog, facts).Build(new ServiceAction(ActionKind.ForceStop, "Netlogon"));
+
+ /// The chain with Netlogon in a shared process - the one shape the question is asked of.
+ private static FakeScmCatalog Shared() =>
+ Rebuild(
+ Housed(("SessionEnv", Netlogon), ("Netlogon", Netlogon)),
+ "Netlogon",
+ entry => entry with { EntryType = EntryType.SharedProcess });
+}
diff --git a/tests/Bws.Gui.Tests/AftermathWordsGuards.cs b/tests/Bws.Gui.Tests/AftermathWordsGuards.cs
index 1ce51a26..33104bac 100644
--- a/tests/Bws.Gui.Tests/AftermathWordsGuards.cs
+++ b/tests/Bws.Gui.Tests/AftermathWordsGuards.cs
@@ -47,6 +47,23 @@ public void The_three_refusals_each_have_a_sentence()
PlanWords.Describe(new PlanProblem(PlanProblemKind.AftermathUnreadable, "Spooler", ["Fax", "Spooler"])));
}
+ [Fact]
+ public void Neighbours_the_manager_did_not_list_are_named_and_no_names_is_the_list_refused()
+ {
+ // Logic report R04, 2026-10-27: three shapes - the list of names refused, one withheld, several.
+ Assert.Equal(
+ Texts.Of("gui.plan.problem.neighboursUnreadable.list", "Netlogon"),
+ PlanWords.Describe(new PlanProblem(PlanProblemKind.NeighboursUnreadable, "Netlogon", [])));
+
+ Assert.Equal(
+ Texts.Of("gui.plan.problem.neighboursUnreadable.one", "Netlogon", "Withheld"),
+ PlanWords.Describe(new PlanProblem(PlanProblemKind.NeighboursUnreadable, "Netlogon", ["Withheld"])));
+
+ Assert.Equal(
+ Texts.Of("gui.plan.problem.neighboursUnreadable.many", "Netlogon", "Kept, Withheld"),
+ PlanWords.Describe(new PlanProblem(PlanProblemKind.NeighboursUnreadable, "Netlogon", ["Kept", "Withheld"])));
+ }
+
[Fact]
public void An_ending_the_manager_undid_at_once_names_the_new_process()
{
diff --git a/tests/Bws.Gui.Tests/WatchedStepWordsGuards.cs b/tests/Bws.Gui.Tests/WatchedStepWordsGuards.cs
new file mode 100644
index 00000000..0b0ee3df
--- /dev/null
+++ b/tests/Bws.Gui.Tests/WatchedStepWordsGuards.cs
@@ -0,0 +1,33 @@
+using Bws.Core;
+using Bws.Core.Planning;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// A request the manager took after which the entry could not be read, in the window's words - logic report
+/// R02, 2026-10-26. Not "would not stop": nothing refused the stop, only the reading after it.
+///
+public sealed class WatchedStepWordsGuards
+{
+ [Fact]
+ public void A_request_taken_and_then_lost_from_sight_is_not_called_refused()
+ {
+ var said = PlanWords.Describe(new StepResult
+ {
+ Step = new PlanStep("Spooler", "Print Spooler", StepOperation.Stop, StepReason.Requested),
+ Outcome = StepOutcome.Failed,
+ SkippedBecause = null,
+ Status = EntryStatus.Unknown,
+ ProcessId = Reading.NotRead(),
+ ErrorCode = 5,
+ Error = "Access is denied.",
+ Milliseconds = 40,
+ LostSight = true
+ });
+
+ Assert.Equal(
+ Texts.Of("gui.plan.failure.lostSight", "Spooler", Texts.Of("gui.plan.operation.stop"), "Access is denied."),
+ said);
+ }
+}
diff --git a/tests/Bws.Integration.Tests/UnlistedContractTests.cs b/tests/Bws.Integration.Tests/UnlistedContractTests.cs
new file mode 100644
index 00000000..1f89aa9a
--- /dev/null
+++ b/tests/Bws.Integration.Tests/UnlistedContractTests.cs
@@ -0,0 +1,35 @@
+using Bws.Core;
+
+namespace Bws.Integration.Tests;
+
+///
+/// The question a plan that ends a shared process asks since 2026-10-27 - which entries the manager keeps and
+/// did not list to this account (logic report R04) - put to the real manager.
+///
+/// Under elevation on the owner's machine the answer is empty (measured 2026-10-26: the listing held
+/// 804 entries and the only key with a type it lacked was a driver the manager does not know), so what can be
+/// held here on any machine is the shape of the answer rather than its content - this test cannot fail on a
+/// broken decision about a type or a refusal, and is not meant to. Those two decisions are tested on their values
+/// in UnlistedNeighbourTests (the review of #94), and the content under a restricted token is what
+/// tools/scm-probe/unlisted-probe.ps1 is for.
+///
+/// Read-only. The registry is opened for reading and every entry for its status alone.
+///
+public sealed class UnlistedContractTests
+{
+ [Fact]
+ public void The_listing_decides_what_is_asked_and_never_the_answer()
+ {
+ var listed = new WindowsScmCatalog().ReadAll().Select(entry => entry.ServiceName).ToList();
+ var reader = new WindowsEndingFactsReader();
+
+ var withTheListing = reader.ReadUnlisted(listed);
+ var withNothing = reader.ReadUnlisted([]);
+
+ // Every entry the listing shows can be queried by this account, so handing the listing in only saves
+ // asking about it - an entry this account can open is never one the enumeration left out.
+ Assert.Equal(ReadOutcome.Present, withTheListing.Outcome);
+ Assert.Equal(ReadOutcome.Present, withNothing.Outcome);
+ Assert.Equal(withTheListing.Value!.Order(StringComparer.OrdinalIgnoreCase), withNothing.Value!.Order(StringComparer.OrdinalIgnoreCase));
+ }
+}
diff --git a/tests/mutation/entries.ps1 b/tests/mutation/entries.ps1
index cb466c56..1f025fd4 100644
--- a/tests/mutation/entries.ps1
+++ b/tests/mutation/entries.ps1
@@ -178,11 +178,12 @@ $mutations = @(
File = 'src\Bws.Core\Planning\CriticalEntries.cs'
#
# RE-ANCHORED 2026-09-24: the condition became PlanBuilder.StopsPolitely when a startup
- # setting of disabled learned to carry a stop. The swap it makes is unchanged.
- From = 'if (PlanBuilder.StopsPolitely(action, target))
+ # setting of disabled learned to carry a stop. The swap it makes is unchanged. Again on
+ # 2026-10-27, when StopsPolitely lost the entry it no longer asks about (logic report R03).
+ From = 'if (PlanBuilder.StopsPolitely(action))
{
var stopping = Named([.. cascade, target]);'
- To = 'if (PlanBuilder.StopsPolitely(action, target))
+ To = 'if (PlanBuilder.StopsPolitely(action))
{
var stopping = Named([]);'
Test = 'CriticalEntryGuards.A_stop_names_the_entry_the_machine_does_not_work_without'
@@ -412,8 +413,9 @@ $mutations = @(
@{
# Windows hands out process numbers again once a process is gone, and a plan is built one
# moment and carried out the next.
+ # Re-filed 2026-10-27: the check moved to PlanRunner.Ending.cs with the size ratchet.
Name = 'forced stop: a process that moved since the preview is left alone'
- File = 'src\Bws.Core\Planning\PlanRunner.cs'
+ File = 'src\Bws.Core\Planning\PlanRunner.Ending.cs'
From = 'return holding.IsPresent && holding.Value == step.ProcessId'
To = 'return holding.IsPresent'
Test = 'EscalationRunTests.A_process_that_is_no_longer_the_one_the_plan_named_is_left_alone'
@@ -462,10 +464,11 @@ $mutations = @(
},
@{
# With --force the neighbours have no steps, and this list is all a way back can learn them from.
+ # Re-anchored 2026-10-27: the recovery lists the preview read now ride on the same step (R05).
Name = 'forced stop: the ending names what it takes with it'
File = 'src\Bws.Core\Planning\ForcedStop.cs'
- From = 'TakesWithIt: [.. ending.Sharing.Select(entry => entry.ServiceName)]));'
- To = 'TakesWithIt: []));'
+ From = 'TakesWithIt: [.. ending.Sharing.Select(entry => entry.ServiceName)],'
+ To = 'TakesWithIt: [],'
Test = 'ForcedStopRefusalTests.The_ending_names_the_neighbours_it_takes_with_it'
},
@{
@@ -492,10 +495,11 @@ $mutations = @(
},
@{
# A dependant that refused is still running on the process - the owner's decision of 2026-09-29.
+ # Re-anchored 2026-10-27: a refused second look holds the steps behind as well (R03).
Name = 'forced stop: a dependant that did not stop holds the ending'
File = 'src\Bws.Core\Planning\PlanRunner.cs'
- From = 'cascadeFailed |= step.Reason == StepReason.Cascade;'
- To = 'cascadeFailed |= step.Reason == StepReason.Restore;'
+ From = 'cascadeFailed |= step.Reason == StepReason.Cascade || wholePlan;'
+ To = 'cascadeFailed |= step.Reason == StepReason.Restore || wholePlan;'
Test = 'ForcedRunTests.A_dependant_that_will_not_stop_holds_the_ending'
},
@{
@@ -572,10 +576,11 @@ $mutations = @(
Test = 'ForcedRunTests.A_neighbour_that_died_with_the_process_is_handed_back'
},
@{
+ # Re-anchored 2026-10-27: a stop that only watched the entry arrive found it stopped as well (R01).
Name = 'way back: a neighbour found already stopped is not handed back'
File = 'src\Bws.Core\Planning\NetEffect.cs'
- From = 'if (result is { SkippedBecause: SkipReason.AlreadyThere, Step.Operation: StepOperation.Stop })'
- To = 'if (result is { SkippedBecause: SkipReason.Cancelled, Step.Operation: StepOperation.Stop })'
+ From = '(result.SkippedBecause == SkipReason.AlreadyThere || result.ArrivedUnasked))'
+ To = '(result.SkippedBecause == SkipReason.Cancelled || result.ArrivedUnasked))'
Test = 'ForcedRunTests.A_neighbour_found_already_stopped_is_not_handed_back'
},
@{
@@ -3209,7 +3214,8 @@ $mutations = @(
# holder's name, which carries accented letters, and came back STALE - Windows PowerShell
# reads a file with no byte order mark differently from how the text was written, so the
# two sides stopped being the same string without either being wrong.
- From = '| Meziantou.Analyzer 3.0.138 |'
+ # Re-anchored 2026-10-27: the analyser moved to 3.0.313 and this anchor stayed behind.
+ From = '| Meziantou.Analyzer 3.0.313 |'
To = '| An analyser |'
Test = 'LicenceNoticeGuards.Every_package_that_ships_is_named_in_the_notices'
},
@@ -4459,10 +4465,11 @@ $mutations = @(
# The writer built into a variable of its own instead of straight into the plan. It still
# compiles, still runs, and is exactly the first step towards a call that skips the plan -
# which is how this rule is described as eroding: one exception at a time, each reasonable.
+ # Re-anchored 2026-10-27: the runner reads the process again before an ending (R05).
Name = 'plan: the writer is never held loose, only handed to a plan'
File = 'src\Bws.Cli\Execution.cs'
- From = ' return new PlanRunner(new WindowsScmControl(), new SystemClock()).Run('
- To = ' var loose = new WindowsScmControl();' + [char]13 + [char]10 + ' return new PlanRunner(loose, new SystemClock()).Run('
+ From = ' return new PlanRunner(new WindowsScmControl(), new SystemClock(), new WindowsEndingFactsReader()).Run('
+ To = ' var loose = new WindowsScmControl();' + [char]13 + [char]10 + ' return new PlanRunner(loose, new SystemClock(), new WindowsEndingFactsReader()).Run('
Test = 'PlanOnlyGuards.Every_writer_that_is_built_goes_straight_into_a_plan_runner'
},
@{
@@ -6989,10 +6996,11 @@ $mutations = @(
# on the other side of a call no test in this project can reach - so the only thing a test
# CAN prove is that the value gets there.
# Re-anchored 2026-09-30: the call shares its line with the company check since package B2.
+ # And 2026-10-27: the call moved after the last look at Stop (R09), the checks stayed in Unfit.
Name = 'ending: the identity the plan froze reaches the call that ends the process'
File = 'src\Bws.Core\Planning\PlanRunner.cs'
- From = ' ? Crowded(step) ?? control.Terminate(holding.Value, step.ProcessCreatedAt)'
- To = ' ? Crowded(step) ?? control.Terminate(holding.Value, null)'
+ From = ' ? control.Terminate(Holding(before).Value, step.ProcessCreatedAt)'
+ To = ' ? control.Terminate(Holding(before).Value, null)'
Test = 'EndingRightsTests.The_ending_is_handed_the_identity_the_plan_froze'
}
@@ -9030,8 +9038,8 @@ $mutations = @(
@{
Name = 'keeps running: not said on a plan that already stops the entry'
File = 'src\Bws.Core\Planning\StartTypeWarnings.cs'
- From = ' && !PlanBuilder.StopsAlong(action, target))'
- To = ' && PlanBuilder.StopsAlong(action, target) == PlanBuilder.StopsAlong(action, target))'
+ From = ' && !PlanBuilder.StopsAlong(action))'
+ To = ' && PlanBuilder.StopsAlong(action) == PlanBuilder.StopsAlong(action))'
Test = 'StartSettingTests.Taking_the_offer_puts_the_stop_after_the_setting_and_drops_the_sentence'
},
@{
@@ -9044,20 +9052,29 @@ $mutations = @(
@{
Name = 'also stop: taking the offer adds the stop step after the setting'
File = 'src\Bws.Core\Planning\PlanBuilder.cs'
- From = ' if (StopsAlong(action, target))
+ From = ' if (StopsAlong(action))
{
AddStops(steps, cascade, target);'
- To = ' if (StopsAlong(action, target) && false)
+ To = ' if (StopsAlong(action) && false)
{
AddStops(steps, cascade, target);'
Test = 'StartSettingTests.Taking_the_offer_puts_the_stop_after_the_setting_and_drops_the_sentence'
},
@{
- Name = 'also stop: no stop step for an entry already stopped'
+ # REVERSED 2026-10-27, cause 6 above: until that day this entry proved there was NO stop step for an
+ # entry read as stopped. Logic report R03 (the owner's decision "Sprawdzenie przed biegiem"): an entry
+ # started between the preview and the press was then set to Disabled and left running under a plan
+ # that said "and stop it". The step is always there now, and the runner says "already there" when it
+ # is not needed - so this puts the old condition back where the step is added.
+ Name = 'also stop: a stop step even for an entry read as stopped'
File = 'src\Bws.Core\Planning\PlanBuilder.cs'
- From = 'action is { Kind: ActionKind.SetStartType, AlsoStop: true } && target.Status != EntryStatus.Stopped;'
- To = 'action is { Kind: ActionKind.SetStartType, AlsoStop: true } && target.Status != EntryStatus.Unknown;'
- Test = 'StartSettingTests.The_offer_adds_no_stop_to_an_entry_already_stopped'
+ From = ' if (StopsAlong(action))
+ {
+ AddStops(steps, cascade, target);'
+ To = ' if (StopsAlong(action) && target.Status != EntryStatus.Stopped)
+ {
+ AddStops(steps, cascade, target);'
+ Test = 'StartSettingTests.The_offer_keeps_its_stop_for_an_entry_read_as_stopped'
},
@{
Name = 'also stop: a stop rides only on disabled'
@@ -10874,10 +10891,11 @@ $mutations = @(
},
@{
# A failed ending counts as one - caught in the terminal, where the report of a refused ending is.
+ # Re-anchored 2026-10-27: an ending lost from sight after it was sent counts as ended (R02).
Name = 'coming back: nothing unless the process was ended'
File = 'src\Bws.Core\Planning\Aftermath.cs'
- From = '(result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut || result.StartedAgain)'
- To = '(result.Outcome is not StepOutcome.Skipped || result.StartedAgain)'
+ From = '(result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut || result.StartedAgain || result.LostSight)'
+ To = '(result.Outcome is not StepOutcome.Skipped || result.StartedAgain || result.LostSight)'
Test = 'ComingBackTextTests.A_run_that_never_ended_the_process_says_nothing_about_coming_back'
},
@{
@@ -13510,8 +13528,9 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) :
@{
# The number in the preview can belong to a process nobody has heard of by the time the step
# runs, and ending it would be a step the preview never showed.
+ # Re-filed 2026-10-27: the check moved to PlanRunner.Ending.cs with the size ratchet.
Name = 'search: a run ends only the process the preview named'
- File = 'src\Bws.Core\Planning\PlanRunner.cs'
+ File = 'src\Bws.Core\Planning\PlanRunner.Ending.cs'
From = 'return holding.IsPresent && holding.Value == step.ProcessId'
To = 'return holding.IsPresent'
Test = 'PlanRunPropertyTests.A_run_asks_only_what_the_preview_showed_and_puts_back_what_it_took_down'
@@ -14064,6 +14083,249 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) :
To = '"history", "license", "snapshot"]'
Test = 'CommandLinePropertyTests.The_words_drawn_above_reach_every_kind_of_command'
}
+ # ---- Logic report, package L1 (running a plan), 2026-10-27 - docs/ANALIZA-RAPORTU-LOGIKI-20261026.md ----
+ @{
+ # R01: an entry that came back from the other way and stopped by itself was nobody's move here.
+ Name = 'run: an arrival after the entry turned round is not this run''s move'
+ File = 'src\Bws.Core\Planning\PlanRunner.Waiting.cs'
+ From = 'Elapsed(started)) with { ArrivedUnasked = true },'
+ To = 'Elapsed(started)) with { ArrivedUnasked = false },'
+ Test = 'PlanRunProgressTests.A_stop_that_meets_an_entry_starting_which_falls_back_to_stopped_puts_nothing_back'
+ }
+ @{
+ # R01: an entry already on its way, only watched arrive, was nobody's move here either.
+ Name = 'run: an arrival before any request is not this run''s move'
+ File = 'src\Bws.Core\Planning\PlanRunner.Waiting.cs'
+ From = 'with { ArrivedUnasked = !asked },'
+ To = 'with { ArrivedUnasked = false },'
+ Test = 'PlanRunProgressTests.A_restart_that_only_watched_an_entry_stop_by_itself_does_not_start_it'
+ }
+ @{
+ # R01, the way back: an arrival nobody asked for never begins a move, so nothing puts it back.
+ Name = 'way back: an arrival nobody asked for never begins a move'
+ File = 'src\Bws.Core\Planning\NetEffect.cs'
+ From = ' else if (!unasked)'
+ To = ' else if (at >= 0)'
+ Test = 'PlanRunProgressTests.A_restart_that_only_watched_an_entry_stop_by_itself_does_not_start_it'
+ }
+ @{
+ # R01, the way back: it still ends a move this run made, so a restart whose start only watched leaves no line.
+ Name = 'way back: an arrival nobody asked for ends a move this run made'
+ File = 'src\Bws.Core\Planning\NetEffect.cs'
+ From = 'moves[name] = (seen.First, operation, at);'
+ To = 'moves[name] = unasked ? seen : (seen.First, operation, at);'
+ Test = 'PlanRunProgressTests.A_start_putting_back_an_entry_somebody_else_was_already_starting_leaves_no_way_back'
+ }
+ @{
+ # R01: a neighbour watched stopping by itself was not in the process when it ended.
+ Name = 'way back: a neighbour watched stopping by itself is not handed back'
+ File = 'src\Bws.Core\Planning\NetEffect.cs'
+ From = '(result.SkippedBecause == SkipReason.AlreadyThere || result.ArrivedUnasked))'
+ To = '(result.SkippedBecause == SkipReason.AlreadyThere || result.StartedAgain))'
+ Test = 'ForcedRunTests.A_neighbour_watched_stopping_by_itself_before_the_ending_is_not_handed_back'
+ }
+ @{
+ Name = 'terminal: an arrival nobody asked for is not called done'
+ File = 'src\Bws.Cli\PlanText.cs'
+ From = ' StepOutcome.Succeeded => result.ArrivedUnasked'
+ To = ' StepOutcome.Succeeded => result.StartedAgain'
+ Test = 'WatchedStepSentenceTests.An_arrival_nothing_asked_for_says_so_rather_than_done'
+ }
+ @{
+ # R02: a request the manager took and then lost from sight is marked as such.
+ Name = 'run: a request lost from sight after the manager took it is marked'
+ File = 'src\Bws.Core\Planning\PlanRunner.Waiting.cs'
+ From = 'with { LostSight = asked },'
+ To = 'with { LostSight = false },'
+ Test = 'PlanRunProgressTests.A_stop_the_manager_took_and_then_lost_from_sight_is_still_this_run_s_move'
+ }
+ @{
+ # R02, the way back: it counts as a move, the way a request that ran out of time does.
+ Name = 'way back: a request lost from sight is this run''s move'
+ File = 'src\Bws.Core\Planning\NetEffect.cs'
+ From = ' && !result.LostSight'
+ To = ' && !result.ArrivedUnasked'
+ Test = 'PlanRunProgressTests.A_stop_the_manager_took_and_then_lost_from_sight_is_still_this_run_s_move'
+ }
+ @{
+ # Not a constant: the earlier arms already take StartedAgain and StoppedWhileStarting, so either would not compile.
+ Name = 'terminal: a request lost from sight is not called refused'
+ File = 'src\Bws.Cli\PlanText.cs'
+ From = ' StepOutcome.Failed when result.LostSight => Texts.Of('
+ To = ' StepOutcome.Failed when result.LostSight && result.ErrorCode == 0 => Texts.Of('
+ Test = 'WatchedStepSentenceTests.A_request_taken_and_then_lost_from_sight_is_not_called_refused'
+ }
+ @{
+ Name = 'window: a request lost from sight is not called refused'
+ File = 'src\Bws.Gui\ViewModels\PlanWords.cs'
+ From = ' { LostSight: true } => Texts.Of('
+ To = ' { LostSight: true, ErrorCode: 0 } => Texts.Of('
+ Test = 'WatchedStepWordsGuards.A_request_taken_and_then_lost_from_sight_is_not_called_refused'
+ }
+ @{
+ # R09: the last look at Stop comes after the readings a step makes, immediately before the request.
+ Name = 'run: Stop pressed while a step gets ready sends nothing'
+ File = 'src\Bws.Core\Planning\PlanRunner.cs'
+ From = 'if (refusal is null && halt.Unasked(step.Reason))'
+ To = 'if (refusal is not null && halt.Unasked(step.Reason))'
+ Test = 'PlanRunHaltTests.Stop_pressed_while_a_step_gets_ready_sends_nothing_and_a_restart_then_puts_nothing_back'
+ }
+ @{
+ Name = 'run: Stop pressed while an ending reads the process ends nothing'
+ File = 'src\Bws.Core\Planning\PlanRunner.cs'
+ From = 'if (refusal is null && halt.Unasked(step.Reason))'
+ To = 'if (refusal is not null && halt.Unasked(step.Reason))'
+ Test = 'PlanRunHaltTests.Stop_pressed_while_an_ending_reads_the_process_ends_nothing'
+ }
+ @{
+ Name = 'run: Stop pressed just before a setting is written writes nothing'
+ File = 'src\Bws.Core\Planning\PlanRunner.cs'
+ From = ' return halt.Unasked(step.Reason)'
+ To = ' return !halt.Unasked(step.Reason)'
+ Test = 'PlanRunHaltTests.Stop_pressed_just_before_a_setting_is_written_writes_nothing'
+ }
+ @{
+ # R05: the run asks again just before it ends a process, and a critical one is not ended.
+ Name = 'ending: a process that became critical after the preview is not ended'
+ File = 'src\Bws.Core\Planning\PlanRunner.Ending.cs'
+ From = 'if (critical is { IsPresent: true, Value: true })'
+ To = 'if (critical is { IsPresent: true, Value: false })'
+ Test = 'SecondLookTests.A_process_that_became_critical_after_the_preview_is_not_ended'
+ }
+ @{
+ Name = 'ending: criticality unreadable just before is a refusal'
+ File = 'src\Bws.Core\Planning\PlanRunner.Ending.cs'
+ From = 'if (critical.Outcome == ReadOutcome.Denied)'
+ To = 'if (critical.Outcome == ReadOutcome.Absent)'
+ Test = 'SecondLookTests.A_process_whose_criticality_cannot_be_read_just_before_is_not_ended'
+ }
+ @{
+ Name = 'ending: a recovery list changed since the preview holds the ending'
+ File = 'src\Bws.Core\Planning\PlanRunner.Ending.cs'
+ From = 'if (!RecoveryList.Same(frozen.Actions, now))'
+ To = 'if (RecoveryList.Same(frozen.Actions, now))'
+ Test = 'SecondLookTests.An_ending_whose_recovery_list_changed_after_the_preview_ends_nothing'
+ }
+ @{
+ # The review of #94: a refused read is said as unread, with the manager's number - not as a changed list.
+ Name = 'ending: a recovery list refused just before is said unread with its number'
+ File = 'src\Bws.Core\Planning\PlanRunner.Ending.cs'
+ From = 'if (now.Outcome == ReadOutcome.Denied)'
+ To = 'if (now.Outcome == ReadOutcome.Absent)'
+ Test = 'SecondLookTests.A_recovery_list_the_manager_refuses_just_before_is_said_unread_with_its_number'
+ }
+ @{
+ # Two readings of one unchanged list are two lists - by reference every ending on a real machine is refused.
+ Name = 'ending: recovery lists are compared by value'
+ File = 'src\Bws.Core\EndingFacts.cs'
+ From = 'first.Value.Items.SequenceEqual(second.Value.Items)'
+ To = 'ReferenceEquals(first.Value.Items, second.Value.Items)'
+ Test = 'SecondLookTests.An_ending_whose_recovery_list_is_what_the_preview_read_goes_ahead'
+ }
+ @{
+ Name = 'ending: the step carries the recovery lists the preview read'
+ File = 'src\Bws.Core\Planning\ForcedStop.cs'
+ From = 'Recovery: ending.Recovery));'
+ To = 'Recovery: null));'
+ Test = 'SecondLookTests.The_builder_freezes_what_it_read_and_the_same_machine_lets_the_run_through'
+ }
+ @{
+ # R03: before the first step the run reads the start type of everything it would take down and put back.
+ Name = 'run: an entry set to Disabled since the preview holds the restart'
+ File = 'src\Bws.Core\Planning\PlanRunner.ComingBack.cs'
+ From = 'is { IsPresent: true, Value: StartType.Disabled })'
+ To = 'is { IsPresent: true, Value: StartType.Manual })'
+ Test = 'SecondLookTests.A_restart_of_an_entry_set_to_Disabled_after_the_preview_stops_nothing'
+ }
+ @{
+ # R03, found by the second analysis of the design: the steps behind (neighbours, the ending) are held too.
+ Name = 'run: a refused second look holds the steps behind as well'
+ File = 'src\Bws.Core\Planning\PlanRunner.cs'
+ From = 'cascadeFailed |= step.Reason == StepReason.Cascade || wholePlan;'
+ To = 'cascadeFailed |= step.Reason == StepReason.Cascade || (wholePlan && step.Reason == StepReason.Cascade);'
+ Test = 'SecondLookTests.A_forced_restart_refused_for_a_disabled_neighbour_ends_no_process'
+ }
+ @{
+ # R08: a startup setting never takes dependants down, whatever the ask carries.
+ Name = 'plan: a setting that stops takes no dependants down'
+ File = 'src\Bws.Core\Planning\PlanBuilder.cs'
+ From = 'asked.IncludeDependents && asked.Kind != ActionKind.SetStartType ? blocking : []'
+ To = 'asked.IncludeDependents ? blocking : []'
+ Test = 'StartSettingTests.A_setting_that_stops_takes_no_dependants_down_whatever_it_carries'
+ }
+ @{
+ # R04: an entry the manager did not list to this account could live in the process.
+ Name = 'forced stop: an entry the manager did not list holds the ending'
+ File = 'src\Bws.Core\Planning\ProcessNeighbours.cs'
+ From = 'ReadOutcome.Present when unlisted.Value!.Count > 0 =>'
+ To = 'ReadOutcome.Present when unlisted.Value!.Count > 1 =>'
+ Test = 'UnlistedNeighbourTests.An_entry_the_manager_did_not_list_is_named_and_nothing_is_ended'
+ }
+ @{
+ Name = 'forced stop: a list of names nobody could read holds the ending'
+ File = 'src\Bws.Core\Planning\ProcessNeighbours.cs'
+ From = 'ReadOutcome.Denied => new PlanProblem(PlanProblemKind.NeighboursUnreadable, target.ServiceName, []),'
+ To = 'ReadOutcome.Absent => new PlanProblem(PlanProblemKind.NeighboursUnreadable, target.ServiceName, []),'
+ Test = 'UnlistedNeighbourTests.A_list_of_names_nobody_could_read_is_a_refusal_naming_nobody'
+ }
+ @{
+ Name = 'forced stop: only a shared process is asked who the manager left out'
+ File = 'src\Bws.Core\Planning\ProcessNeighbours.cs'
+ From = 'if (target.EntryType != EntryType.SharedProcess)'
+ To = 'if (target.EntryType == EntryType.Unknown)'
+ Test = 'UnlistedNeighbourTests.An_entry_with_a_process_of_its_own_has_no_neighbours_to_leave_out_and_is_not_asked'
+ }
+ @{
+ Name = 'forced stop: the question carries the whole listing'
+ File = 'src\Bws.Core\Planning\ProcessNeighbours.cs'
+ From = 'processes.ReadUnlisted(entries.Select(entry => entry.ServiceName))'
+ To = 'processes.ReadUnlisted(entries.Skip(1).Select(entry => entry.ServiceName))'
+ Test = 'UnlistedNeighbourTests.The_question_carries_every_name_of_the_listing_the_plan_was_built_from'
+ }
+ @{
+ # The review of #94: the two decisions of the real reader, on their values.
+ Name = 'unlisted: only the shared bit lets a type live beside another'
+ File = 'src\Bws.Core\WindowsEndingFactsReader.Unlisted.cs'
+ From = 'int bits => (bits & SharesAProcess) != 0,'
+ To = 'int bits => bits != 0,'
+ Test = 'UnlistedNeighbourTests.Only_a_type_with_the_shared_bit_can_live_beside_another'
+ }
+ @{
+ Name = 'unlisted: a key with no type is no entry'
+ File = 'src\Bws.Core\WindowsEndingFactsReader.Unlisted.cs'
+ From = ' null => false,'
+ To = ' null => true,'
+ Test = 'UnlistedNeighbourTests.A_key_with_no_type_is_no_entry_and_a_type_of_another_shape_is_asked_about'
+ }
+ @{
+ Name = 'unlisted: a name the manager does not hold is not withheld'
+ File = 'src\Bws.Core\WindowsEndingFactsReader.Unlisted.cs'
+ From = 'code is not ((int)WIN32_ERROR.ERROR_SERVICE_DOES_NOT_EXIST or (int)WIN32_ERROR.ERROR_INVALID_NAME);'
+ To = 'code is not (int)WIN32_ERROR.ERROR_INVALID_NAME;'
+ Test = 'UnlistedNeighbourTests.Only_a_name_the_manager_holds_and_refuses_is_withheld'
+ }
+ @{
+ Name = 'forced stop: the plan asks the reader it was given'
+ File = 'src\Bws.Core\Planning\ForcedStop.cs'
+ From = 'if (ProcessNeighbours.Unlisted(processes, entries, target) is { } unlisted)'
+ To = 'if (ProcessNeighbours.Unlisted(NobodyToAsk.Instance, entries, target) is { } unlisted)'
+ Test = 'UnlistedNeighbourTests.An_entry_the_manager_did_not_list_is_named_and_nothing_is_ended'
+ }
+ @{
+ # R05: a runner given no reader asks nothing, so the terminal's would end a process on the preview alone.
+ Name = 'plan: the terminal''s runner reads the process again before an ending'
+ File = 'src\Bws.Cli\Execution.cs'
+ From = 'new SystemClock(), new WindowsEndingFactsReader()).Run('
+ To = 'new SystemClock()).Run('
+ Test = 'PlanOnlyGuards.Every_runner_on_the_real_machine_reads_the_process_again_before_ending_it'
+ }
+ @{
+ Name = 'plan: the window''s runner reads the process again before an ending'
+ File = 'src\Bws.Gui\Carrying.cs'
+ From = 'new SystemClock(), new WindowsEndingFactsReader()));'
+ To = 'new SystemClock()));'
+ Test = 'PlanOnlyGuards.Every_runner_on_the_real_machine_reads_the_process_again_before_ending_it'
+ }
)
$mutations