diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c79bc4..beeb024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,28 @@ is not part of this repository. doing nothing. - After a reading of the list fails once and the next one works, the window says so straight away. It used to go on saying it could not read the list until something on the machine changed. +- A force stop no longer ends a process under services that still need it. While services depending + on the entry are running outside the plan, `bws kill` and the window's Force stop refuse and name + them. The same goes for running services that depend on anything sharing the entry's process. + `bws kill NAME --dependents` still stops the dependents as part of the plan, and if one of them + will not stop, the process is not ended. +- `bws kill NAME --force --dependents` is refused. Its preview listed the dependents as stopping, + while the run skipped their stops along with the polite one and ended the process under them. +- A force stop asks the entry itself to stop before the other services sharing its process, and asks + those only if the entry did not stop. A service sharing the process that refused to stop used to + make the plan skip the entry's own polite stop and end the process at once, and when the entry + would have stopped on its own, the others had been stopped for nothing. In the JSON of a run, the + steps not needed are reported with `"skippedBecause": "processStays"`, and the run still counts as + completed. +- The preview of a force stop names a critical service arriving with `--dependents`, a service + sharing the process that does not accept a stop, and one that is disabled and could not be + started again by `--restart`. +- The way back after a force stop includes the services that ended with the process without stopping + on their own. After `bws kill NAME --restart --force` it used to tell you to stop a service that was + running before and after. +- After a stop refused because other running services depend on the entry, or because of missing + rights, the window no longer offers Force stop, which could not help with either. +- The equivalent command of a force stop planned with its dependents includes `--dependents`. ## [0.3.0] - 2026-09-25 diff --git a/README.md b/README.md index 410e85b..bf1b616 100644 --- a/README.md +++ b/README.md @@ -265,9 +265,15 @@ line that asks for the same thing, and the way back afterwards. The window greys the button and says why beside it. - **Force stop is two steps, and the second is conditional.** `bws kill` asks the service to stop and ends its process only if that does not work. The preview names the process and every other service - living in it, because ending a process takes all of them. `--restart` brings them back, `--force` - skips the polite step - and the preview shows one step instead of two, so the difference is visible - before anything happens. + living in it, because ending a process takes all of them - and those are asked to stop only when the + service itself did not, so a polite stop that works leaves them running. `--restart` brings them + back, `--force` skips the polite step - and the preview shows one step instead of two, so the + difference is visible before anything happens. +- **Force stop never pulls a process out from under a running service.** While services that depend + on it, or on anything sharing its process, are running outside the plan, `bws kill` refuses and + names them. `--dependents` stops the first kind as part of the plan, and a dependant that will not + stop holds the process ending back. `--force` does not go with `--dependents`, because skipping the + polite step would skip theirs too. - **Afterwards, the way back.** A report ends with what did not work and the commands that put things back - and the window offers *Copy all* over them. - **A start type change moves nothing on its own.** It changes what happens at the next boot, leaves diff --git a/src/Bws.Cli/PlanText.cs b/src/Bws.Cli/PlanText.cs index 7660397..0eab51f 100644 --- a/src/Bws.Cli/PlanText.cs +++ b/src/Bws.Cli/PlanText.cs @@ -323,10 +323,31 @@ private static string Took(long milliseconds) => milliseconds < 1000 PlanProblemKind.CannotStartLate => Texts.Of( "cli.plan.problem.cannotStartLate", problem.ServiceName, Join(problem.Related)), + PlanProblemKind.DependentsInTheWay or PlanProblemKind.NeighbourNeeded => StillRunning(problem), + _ => throw new ArgumentOutOfRangeException( nameof(problem), problem.Kind, EquivalentCommand.Unhandled) }; + /// + /// The two refusals of a forced stop that name entries still running, since 2026-09-29. + /// + /// Both name them, because they are what somebody has to stop first. Only the first offers + /// --dependents, which reaches what depends on the entry itself and nothing else. Out of + /// Describe because that switch stands close to the complexity ceiling. + /// + private static string StillRunning(PlanProblem problem) => problem.Kind == PlanProblemKind.DependentsInTheWay + ? Texts.Of( + problem.Related.Count == 1 + ? "cli.plan.problem.dependentsInTheWay.one" + : "cli.plan.problem.dependentsInTheWay.many", + problem.ServiceName, Join(problem.Related)) + : Texts.Of( + problem.Related.Count == 1 + ? "cli.plan.problem.neighbourNeeded.one" + : "cli.plan.problem.neighbourNeeded.many", + problem.ServiceName, Join(problem.Related)); + private static bool IsTheTarget(PlanProblem problem) => problem.Related.Count == 1 && string.Equals(problem.Related[0], problem.ServiceName, StringComparison.OrdinalIgnoreCase); diff --git a/src/Bws.Cli/Refusals.cs b/src/Bws.Cli/Refusals.cs index 5b42614..477d056 100644 --- a/src/Bws.Cli/Refusals.cs +++ b/src/Bws.Cli/Refusals.cs @@ -215,6 +215,11 @@ internal static class Refusals } } + if (AboutTheForcing(options) is { } forced) + { + return forced; + } + if (WriteCommands.NeedsAStartType(options.Kind) && AboutTheSetting(options.ServiceName, options.Setting) is { } code) { return code; @@ -229,6 +234,27 @@ internal static class Refusals return null; } + /// + /// What is wrong with a kill ask, or nothing. + /// + /// --force skips every stop in front of the ending, the cascade's too, so beside --dependents + /// it promised dependants stopped and then ended the process under them - the preview and the run + /// disagreeing, which is rule 5 of the untouchable list (stability report W-4, 2026-09-29). Refused + /// here with both ways out, before anything is read. The core refuses the same shape loudly behind + /// this. Its own method because AboutTheAsk stands close to the complexity ceiling. + /// + private static int? AboutTheForcing(CommandLine options) + { + if (options.Kind != CommandKind.Kill || !options.Force || !options.Dependents) + { + return null; + } + + Console.Error.WriteLine(Texts.Of("cli.forceWithDependents", options.ServiceName)); + + return ExitCode.Usage; + } + /// /// What is wrong with a start-type ask, or nothing. /// diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 954bdb3..8d57134 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -40,6 +40,7 @@ "cli.missingStartType": "start-type needs a start type after the name. Example: bws start-type {0} manual --dry-run\nThe start types are: {1}", "cli.badStartType": "{0} is not a start type. The start types are: {1}", "cli.stopNeedsDisabled": "--stop goes only with disabled. To stop an entry you set to {0}, run bws stop with its name after this command.", + "cli.forceWithDependents": "--force and --dependents cannot go together. --force skips every stop before the process is ended, so the entries depending on {0} would still be running when it goes. Drop --force to have them stopped first, or stop them yourself and then use --force.", "cli.badTimeout": "--timeout takes a whole number of seconds, at least 1. It got: {0}", "cli.plan.heading.one": "Plan: {0} {1} (1 step)", @@ -103,6 +104,7 @@ "cli.run.outcome.alreadyThere": "already there, nothing to do", "cli.run.outcome.earlierStepFailed": "not tried, an earlier step did not work", "cli.run.outcome.cancelled": "not tried, the run was interrupted", + "cli.run.outcome.processStays": "not needed, the process it lives in is not being ended", "cli.run.took.milliseconds": "{0} ms", "cli.run.took.seconds": "{0} s", @@ -118,7 +120,11 @@ "cli.plan.problem.processCannotBeEnded": "Windows will not let this tool end process {1}, which is the process {0} runs in. It said: {2} (error {3}). Nothing was changed. A process the system protects is the usual reason, and no tool running as you gets past it.", "cli.plan.problem.processCannotBeEnded.plain": "Windows will not let this tool end process {1}, which is the process {0} runs in. Nothing was changed. A process the system protects is the usual reason, and no tool running as you gets past it.", "cli.plan.problem.cannotStartLate": "{0} belongs to the load order group {1}, and Windows does not let such an entry start late. Nothing was changed. The word automatic starts it at boot with the others.", - "cli.plan.problem.cascadeUnreadable":"Could not read everything that depends on {0}, so the list of what ending its process would take down would be shorter than the truth. There is no preview this tool can honestly offer for that.", + "cli.plan.problem.dependentsInTheWay.one": "{1} depends on {0} and is still running. Ending the process under it is exactly what Windows refuses an ordinary stop for, so there is no plan. Stop it first, or add --dependents to stop it as part of this one.", + "cli.plan.problem.dependentsInTheWay.many": "These depend on {0} and are still running: {1}. Ending the process under them is exactly what Windows refuses an ordinary stop for, so there is no plan. Stop them first, or add --dependents to stop them as part of this one.", + "cli.plan.problem.neighbourNeeded.one": "{1} is running and depends on an entry that shares the process of {0}, which ending that process would take down. There is no plan. Stop {1} first.", + "cli.plan.problem.neighbourNeeded.many": "These are running and depend on an entry that shares the process of {0}, which ending that process would take down: {1}. There is no plan. Stop them first.", + "cli.plan.problem.cascadeUnreadable":"Could not read everything that depends on {0} or on what shares its process, so the list of what ending its process would take down would be shorter than the truth. There is no preview this tool can honestly offer for that.", "cli.column.name": "NAME", "cli.column.displayName": "DISPLAY NAME", diff --git a/src/Bws.Core/Planning/EquivalentCommand.cs b/src/Bws.Core/Planning/EquivalentCommand.cs index 4bd3fa0..cc14ff8 100644 --- a/src/Bws.Core/Planning/EquivalentCommand.cs +++ b/src/Bws.Core/Planning/EquivalentCommand.cs @@ -112,9 +112,15 @@ public static string For(ServiceAction action) // nothing, carried in from a selection whose tick box belongs to a different question. That // is the same shape as the nine two-way branches this file's Unhandled constant describes: // a condition phrased as everything-except answers for kinds nobody has written yet. + // + // KILL TAKES IT TOO AND WAS MISSING HERE UNTIL 2026-09-29 (stability report W-12). The command + // line has accepted it on kill from the day the verb arrived, so a forced stop planned with + // its dependents came back as a line that, pasted, planned the stop without them - which since + // that day is a refusal rather than a quieter plan. var switches = new List(); - if (action.IncludeDependents && action.Kind is ActionKind.Stop or ActionKind.Restart) + if (action.IncludeDependents + && action.Kind is ActionKind.Stop or ActionKind.Restart or ActionKind.ForceStop or ActionKind.ForceRestart) { switches.Add(Dependents); } diff --git a/src/Bws.Core/Planning/ForcedStop.cs b/src/Bws.Core/Planning/ForcedStop.cs index f08f200..fe50233 100644 --- a/src/Bws.Core/Planning/ForcedStop.cs +++ b/src/Bws.Core/Planning/ForcedStop.cs @@ -46,14 +46,21 @@ internal static bool Asked(ActionKind kind) => /// manager a second time would be a second answer to a question with one answer, and the two /// could disagree. /// + /// + /// Every running entry that depends on the target, whether or not the plan stops them - the plan + /// stops them exactly when the ask carries . + /// internal static (PlanProblem? Refusal, Ending? Ending) Decide( IReadOnlyList entries, + IScmCatalog catalog, ScmEntry target, - IReadOnlyList cascade, + IReadOnlyList blocking, IReadOnlyList warnings, - bool immediate, + ServiceAction action, EndingFacts facts) { + ThrowIfTheCourtesySkipsTheCascade(action); + if (ProcessNeighbours.Endable(target) is not { } processId) { // Nothing to name in the preview, so there is no preview. Four quite different readings @@ -80,6 +87,13 @@ internal static (PlanProblem? Refusal, Ending? Ending) Decide( return (Because(PlanProblemKind.CascadeUnreadable, target), null); } + if (InTheWay(action, target, blocking) is { } inTheWay) + { + return (inTheWay, null); + } + + IReadOnlyList cascade = action.IncludeDependents ? blocking : []; + // Minus anything the cascade is already taking down, because an entry named twice in one // plan is two steps doing one thing - and the second reports "already there" in a report // somebody is reading carefully. @@ -90,10 +104,15 @@ .. ProcessNeighbours.Of(entries, target) other.ServiceName, entry.ServiceName, StringComparison.OrdinalIgnoreCase))) ]; + if (Needed(catalog, entries, target, cascade, sharing) is { } needed) + { + return (needed, null); + } + return (null, new Ending( processId, sharing, - immediate, + action.Immediate, facts.Created.IsPresent ? facts.Created.Value : null)); } @@ -136,6 +155,104 @@ .. ProcessNeighbours.Of(entries, target) Error: rights.Reason); } + /// + /// NOT A REFUSAL A PERSON CAN MEET, the same kind of loud as PlanBuilder.ThrowIfNobodyCouldAsk. + /// + /// Skipping the courtesy skips every stop in front of the ending, the cascade's too, so a plan + /// built from this shape promised its dependants stopped and then ended the process under them - + /// the preview saying one thing and the run doing another, which is rule 5 of the untouchable list + /// (stability report W-4). The command line refuses the pair before anything is read, and the + /// window never asks for dependants. + /// + private static void ThrowIfTheCourtesySkipsTheCascade(ServiceAction action) + { + if (action is { Immediate: true, IncludeDependents: true }) + { + throw new ArgumentException( + "Skipping the courtesy skips the cascade too, so an immediate ask cannot carry its " + + "dependents. Refuse the pair where it is typed.", + nameof(action)); + } + } + + /// + /// The running dependants a plan without them would leave standing on a process that is gone. + /// + /// A REFUSAL WHERE AN ORDINARY STOP GETS A WARNING, on the owner's decision of 2026-09-29. + /// The manager refuses that stop with 1051 and nothing is harmed. The last step here asks no + /// manager, and ends the process under entries still running on it. + /// + private static PlanProblem? InTheWay(ServiceAction action, ScmEntry target, IReadOnlyList blocking) => + action.IncludeDependents || blocking.Count == 0 + ? null + : new PlanProblem( + PlanProblemKind.DependentsInTheWay, + target.ServiceName, + [.. blocking.Select(entry => entry.ServiceName)]); + + /// + /// Whether something running outside the plan needs an entry that dies only because it shares the + /// process - and a refusal naming what, when it does. + /// + /// The same question asks about the target, + /// asked of its neighbours, and the external stability report (W-6) is why it is asked at all. + /// Nobody asked about them before 2026-09-29, so a neighbour holding up a running entry was ended + /// under it without a word in the preview. + /// + /// The manager is asked rather than the declarations inverted, for the reason + /// PlanBuilder.StoppingOrder gives: an entry can depend on a load order group, and the declaration + /// does not say who belongs to it. One question per neighbour, and 105 of 110 processes on a + /// measured machine hold one service, so on the ordinary plan there is nobody to ask. + /// + /// An unreadable answer is a refusal - a casualty list known to be short, exactly as for the + /// target's own cascade in . + /// + private static PlanProblem? Needed( + IScmCatalog catalog, + IReadOnlyList entries, + ScmEntry target, + IReadOnlyList cascade, + IReadOnlyList sharing) + { + var dying = new HashSet( + cascade.Concat(sharing).Append(target).Select(entry => entry.ServiceName), + StringComparer.OrdinalIgnoreCase); + + var needing = new List(); + + foreach (var neighbour in sharing) + { + var dependents = catalog.ReadDependents(neighbour.ServiceName); + + if (dependents.Outcome == ReadOutcome.Denied) + { + return Because(PlanProblemKind.CascadeUnreadable, target); + } + + needing.AddRange(Running(entries, dependents).Where(name => !dying.Contains(name))); + } + + return needing.Count == 0 + ? null + : new PlanProblem( + PlanProblemKind.NeighbourNeeded, + target.ServiceName, + [.. needing.Distinct(StringComparer.OrdinalIgnoreCase)]); + } + + /// + /// The names in a dependants answer that are running on this listing. Absent and unread give + /// nobody, which is what PlanBuilder.StoppingOrder makes of them too. + /// + private static IEnumerable Running( + IReadOnlyList entries, Reading> dependents) => + !dependents.IsPresent + ? [] + : entries + .Where(entry => entry.Status != EntryStatus.Stopped + && dependents.Value!.Contains(entry.ServiceName, StringComparer.OrdinalIgnoreCase)) + .Select(entry => entry.ServiceName); + /// The plain shape, for the reasons that carry nothing but a name. private static PlanProblem Because(PlanProblemKind kind, ScmEntry target) => new(kind, target.ServiceName, []); @@ -143,9 +260,18 @@ private static PlanProblem Because(PlanProblemKind kind, ScmEntry target) => /// /// Ask everything politely, then end what is left. /// - /// The neighbours are asked first and they were never in anybody's request. They die - /// when the process does, so the only question is whether they get to close their files on the - /// way - and asking costs one step in a preview that already names them. + /// The order is the cascade, the entry itself, the neighbours, the ending - and until + /// 2026-09-29 the neighbours came BEFORE the entry. The external stability report (W-2) found + /// what that cost: a neighbour refusing its stop was a failed step forward, which skipped the + /// polite stop of the entry itself, and the process was ended at once. And where the entry would + /// have stopped politely, the neighbours had been taken down for nothing - the process stays when + /// its entry stops by itself, and so do they. So the entry is asked first, and the neighbours are + /// asked only on the way to an ending that is actually going to happen: PlanRunner skips them as + /// otherwise. + /// + /// The neighbours were never in anybody's request. They die when the process does, so the + /// only question is whether they get to close their files on the way - and asking costs one step + /// in a preview that already names them. /// /// The entry somebody asked about gets a polite step only if it has not already had one. /// From the window's offer under a failure this plan is built after a stop that gave up, so the @@ -172,14 +298,14 @@ internal static void AddSteps( steps.Add(step(dependent, StepOperation.Stop, StepReason.Cascade)); } - foreach (var sharing in ending.Sharing) + if (!ProcessNeighbours.AlreadyAsked(target)) { - steps.Add(step(sharing, StepOperation.Stop, StepReason.SharesTheProcess)); + steps.Add(step(target, StepOperation.Stop, StepReason.Requested)); } - if (!ProcessNeighbours.AlreadyAsked(target)) + foreach (var sharing in ending.Sharing) { - steps.Add(step(target, StepOperation.Stop, StepReason.Requested)); + steps.Add(step(sharing, StepOperation.Stop, StepReason.SharesTheProcess)); } } @@ -195,15 +321,22 @@ internal static void AddSteps( // escalation would print "if the stop does not work" over a plan with no stop in it. ending.Immediate ? StepReason.Requested : StepReason.Escalation, ProcessId: ending.ProcessId, - ProcessCreatedAt: ending.CreatedAt)); + ProcessCreatedAt: ending.CreatedAt, + TakesWithIt: [.. ending.Sharing.Select(entry => entry.ServiceName)])); } /// - /// Everything that comes back up, in the mirror of the order it went down. + /// Everything that comes back up: the entry, its neighbours, then the cascade in the mirror of the + /// order it went down. /// /// Two loops rather than a reversal of the plan, and the neighbours are why they were worth /// making steps. What has a step going down has a step coming back, worked out here rather /// than guessed at afterwards from what happened. + /// + /// The entry before its neighbours although it went down before them since 2026-09-29 - + /// the ending takes them down in one moment, so there is no order to mirror between the two, and + /// the entry somebody asked about is the one worth having back first. The cascade still comes up + /// last, because every one of them depends on the entry. /// internal static void AddRestores( List steps, @@ -233,7 +366,8 @@ internal static void AddRestores( /// and the exact opposite of what happens here. Two warnings contradicting each other about one /// machine on one screen would be worse than either alone. /// - internal static void AddWarnings(List warnings, ScmEntry target, Ending ending) + internal static void AddWarnings( + List warnings, ScmEntry target, IReadOnlyList cascade, Ending ending) { // Said even though every one of them is already a STEP, because the steps say they will be // asked to stop and this says what happens to the ones that do not. @@ -249,7 +383,11 @@ internal static void AddWarnings(List warnings, ScmEntry target, En // where a person takes down an entry the machine needs without ever typing its name. // Glossary pitfall P1: this says "you should not", which is a different sentence from "you // cannot" and from "confirm that you mean it", and the wording keeps them apart. - var critical = CriticalEntries.Named(ending.Sharing.Append(target)); + // + // THE CASCADE TOO, SINCE 2026-09-29 (stability report W-6). The ordinary stop asks it through + // CriticalEntries.AddWarnings, which does not run for this kind - so a critical entry arriving + // with --dependents on a forced stop was taken down without the sentence that plan exists to say. + var critical = CriticalEntries.Named([.. cascade, .. ending.Sharing, target]); if (critical.Count > 0) { diff --git a/src/Bws.Core/Planning/NetEffect.cs b/src/Bws.Core/Planning/NetEffect.cs index 4dfd6ef..f01df68 100644 --- a/src/Bws.Core/Planning/NetEffect.cs +++ b/src/Bws.Core/Planning/NetEffect.cs @@ -60,7 +60,9 @@ public static class NetEffect /// 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. /// - /// Skipped steps moved nothing by definition, and a refusal moved nothing either. + /// 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 + /// step said, and counts it. /// /// The order is the reverse of the run, and it has to be: a stop cascade takes the /// dependants down before the entry they depend on, so putting them back starts that entry @@ -89,6 +91,51 @@ public static IReadOnlyList Of(IEnumerable results) { ArgumentNullException.ThrowIfNull(results); + var (moves, settings) = Tally(results); + + var back = moves + .Where(move => Before(move.Value.First) != After(move.Value.Last)) + .Select(move => ( + Step: new ReversalStep(move.Key, Undoing(move.Value.Last)), + move.Value.When, + Setting: false)) + .Concat(settings + .Where(written => Nameable(written.Value.From) && written.Value.From != written.Value.To) + .Select(written => ( + Step: new ReversalStep(written.Key, StepOperation.SetStartType, written.Value.From), + When: moves.TryGetValue(written.Key, out var moved) + ? Math.Max(written.Value.When, moved.When) + : written.Value.When, + Setting: true))); + + // Sorted once over both, rather than each list sorted and then joined. The order is the + // reverse of the RUN, and two lists appended would put every setting after every move + // whatever the machine actually did. + // + // WITH ONE EXCEPTION, AND IT ARRIVED WITH THE FIRST RUN THAT BUILDS BOTH (2026-09-24): an + // entry that was set to disabled and then stopped. The reverse of that run starts it first - + // and a disabled entry refuses to start, so the first line pasted would fail. The setting of + // an entry goes back BEFORE that entry's own move, wherever it stood in the run: writing a + // setting never depends on where the entry is, and starting it may depend on the setting. + return [.. back + .OrderByDescending(one => one.When) + .ThenByDescending(one => one.Setting) + .Select(one => one.Step)]; + } + + /// + /// Where each entry was first and last moved, and what each startup setting was before and after - + /// the two tallies turns into lines. + /// + /// Out of Of on 2026-09-29, when the neighbours of an ended process joined the count + /// (stability report W-10) and Of went past the length the shape guard calls close to its ceiling. + /// The seam is the one the method already had: counting what happened, then saying what undoes it. + /// + private static ( + Dictionary Moves, + Dictionary Settings) + Tally(IEnumerable results) + { var moves = new Dictionary( StringComparer.OrdinalIgnoreCase); @@ -100,21 +147,35 @@ public static IReadOnlyList Of(IEnumerable results) // A run like that exists since 2026-09-24 - a startup setting of disabled carrying a stop, // spec C4 - and the entry gets both lines instead of quietly losing one, which is what this // arithmetic was written for before anything built one. The order of the two is decided - // where the lines are sorted, below. + // where the lines are sorted, in Of. 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. + var foundStopped = new HashSet(StringComparer.OrdinalIgnoreCase); + var index = 0; foreach (var result in results) { var at = index++; + if (result is { SkippedBecause: SkipReason.AlreadyThere, Step.Operation: StepOperation.Stop }) + { + foundStopped.Add(result.Step.ServiceName); + } + if (result.Outcome != StepOutcome.Succeeded && result.Outcome != StepOutcome.TimedOut) { continue; } + if (result.Step.Operation == StepOperation.Terminate) + { + WentWith(result.Step, foundStopped, moves, at); + } + var name = result.Step.ServiceName; if (result.Step.Operation == StepOperation.SetStartType) @@ -137,34 +198,34 @@ public static IReadOnlyList Of(IEnumerable results) : (operation, operation, at); } - var back = moves - .Where(move => Before(move.Value.First) != After(move.Value.Last)) - .Select(move => ( - Step: new ReversalStep(move.Key, Undoing(move.Value.Last)), - move.Value.When, - Setting: false)) - .Concat(settings - .Where(written => Nameable(written.Value.From) && written.Value.From != written.Value.To) - .Select(written => ( - Step: new ReversalStep(written.Key, StepOperation.SetStartType, written.Value.From), - When: moves.TryGetValue(written.Key, out var moved) - ? Math.Max(written.Value.When, moved.When) - : written.Value.When, - Setting: true))); - - // Sorted once over both, rather than each list sorted and then joined. The order is the - // reverse of the RUN, and two lists appended would put every setting after every move - // whatever the machine actually did. - // - // WITH ONE EXCEPTION, AND IT ARRIVED WITH THE FIRST RUN THAT BUILDS BOTH (2026-09-24): an - // entry that was set to disabled and then stopped. The reverse of that run starts it first - - // and a disabled entry refuses to start, so the first line pasted would fail. The setting of - // an entry goes back BEFORE that entry's own move, wherever it stood in the run: writing a - // setting never depends on where the entry is, and starting it may depend on the setting. - return [.. back - .OrderByDescending(one => one.When) - .ThenByDescending(one => one.Setting) - .Select(one => one.Step)]; + return (moves, settings); + } + /// + /// The neighbours an ended process took down with it, counted as moved by the step that ended it. + /// + /// THE EXTERNAL STABILITY REPORT FOUND THEM MISSING (W-10), and a live forced restart would have + /// been worse than missing. A neighbour whose polite stop was refused, or that never had one + /// because the courtesy was skipped, has no step of its own that moved it - yet the process it + /// lived in is gone. Left out, its way back was silence after a forced stop, and after a forced + /// restart with --force it was "stop it", worked out from the one step that brought it + /// back, about an entry that had been running all along. + /// + /// Only neighbours with no move of their own and not found already stopped. One whose own + /// stop worked is counted already, and one its step found stopped was not in the process at all. + /// + private static void WentWith( + PlanStep ending, + HashSet foundStopped, + Dictionary moves, + int at) + { + foreach (var name in ending.TakesWithIt ?? []) + { + if (!moves.ContainsKey(name) && !foundStopped.Contains(name)) + { + moves[name] = (StepOperation.Terminate, StepOperation.Terminate, at); + } + } } /// diff --git a/src/Bws.Core/Planning/OperationPlan.cs b/src/Bws.Core/Planning/OperationPlan.cs index e3a25dd..ead15bb 100644 --- a/src/Bws.Core/Planning/OperationPlan.cs +++ b/src/Bws.Core/Planning/OperationPlan.cs @@ -99,8 +99,14 @@ public enum StepReason /// either one needing the other, and a preview claiming a dependency that is not there is a /// preview somebody could reasonably act on. /// - /// It is a stop like any other - asked politely, first, so the entry gets a chance to - /// close its files before the process it lives in goes away. + /// It is a stop like any other - asked politely, so the entry gets a chance to close its + /// files before the process it lives in goes away. + /// + /// Asked AFTER the entry somebody named, and only on the way to an ending that will happen, + /// since 2026-09-29 (stability report W-2). A polite stop of that entry which works leaves the + /// process where it is, and these with it - the run then skips them as + /// . A failure of one of them holds nothing back, because it + /// dies with the process either way. /// SharesTheProcess, @@ -135,6 +141,11 @@ public enum StepReason /// dies either way - so declining to run it would deliver LESS than what was shown, which is /// the same fault as delivering more. An interruption is the opposite: somebody has said stop, /// and ending a process after that would be acting on an instruction that was withdrawn. + /// + /// With one exception since 2026-09-29, the owner's decision on the stability report (W-2): + /// a CASCADE step that did not arrive holds it. That dependant is still running on the process, + /// and ending the process under it is what the manager's own refusal was protecting. The entry + /// itself or a neighbour failing is still exactly the situation this step is for. /// Escalation } @@ -238,6 +249,21 @@ public sealed record ServiceAction( /// Nothing here is a state, not an oversight. A plan built without anything to ask carries /// no time, and the run then checks the number alone - exactly what it did before this existed. /// +/// +/// The entries that die with the process a step of kind ends, +/// by service name, and nothing at all for the other three. +/// +/// ON THE STEP BECAUSE A PLAN THAT SKIPS THE COURTESY HAS NO OTHER STEP NAMING THEM. Without +/// --force every neighbour is a stop step of its own, and the way back could be worked out +/// from those. With it there is one step, and until 2026-09-29 the neighbours it took down were +/// simply missing from the way back - and a forced restart handed back "stop it" for a neighbour +/// that had been running before and was running after. The same list is what the run will one day +/// compare the process against just before ending it (paczka B2 of the stability report), which is +/// the second reason it is frozen here rather than looked up again. +/// +/// 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. +/// public sealed record PlanStep( string ServiceName, string DisplayName, @@ -246,7 +272,8 @@ public sealed record PlanStep( StartSetting? To = null, StartSetting? From = null, int? ProcessId = null, - long? ProcessCreatedAt = null); + long? ProcessCreatedAt = null, + IReadOnlyList? TakesWithIt = null); /// Why a plan could not be made at all. public enum PlanProblemKind @@ -350,7 +377,33 @@ public enum PlanProblemKind /// A step the manager is known to refuse is not a preview of anything. Related names the group, /// because "which group" is the one fact a person could act on. /// - CannotStartLate + CannotStartLate, + + /// + /// Entries depending on the one somebody asked to force are running and the plan does not stop + /// them. Related names them. + /// + /// THE SAME FACT AS AND A HARDER ANSWER, the + /// owner's decision of 2026-09-29. On an ordinary stop the manager refuses with error 1051 and + /// nothing is harmed, so a warning is enough. A forced stop does not ask the manager at its last + /// step - it ends the process under entries that still need it, which is exactly what that 1051 + /// exists to prevent. Found by the external stability report (W-4): the window offered a forced + /// stop under a 1051, and the plan behind the offer ended the process. + /// + /// The command line answers it with --dependents, which puts them in the plan as a + /// cascade. The window has no such tick box and says to stop them first. + /// + DependentsInTheWay, + + /// + /// A running entry the plan does not stop depends on an entry that shares the process, and that + /// neighbour dies when the process does. Related names the running entries. + /// + /// Apart from because the way out is different. + /// --dependents reaches what depends on the TARGET - these depend on something that merely + /// lives beside it, so the only answer is to stop them first. The same decision of 2026-09-29. + /// + NeighbourNeeded } /// 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 7e2f0cd..3a16dce 100644 --- a/src/Bws.Core/Planning/PlanBuilder.cs +++ b/src/Bws.Core/Planning/PlanBuilder.cs @@ -87,7 +87,7 @@ public OperationPlan Build(ServiceAction action) if (ForcedStop.Asked(action.Kind)) { var (refusal, decided) = ForcedStop.Decide( - entries, target, cascade, warnings, action.Immediate, Ask(target)); + entries, catalog, target, blocking, warnings, action, Ask(target)); if (refusal is { } why) { @@ -97,7 +97,7 @@ public OperationPlan Build(ServiceAction action) ending = decided; } - if (StuckDown(action.Kind, target, cascade) is { Count: > 0 } cannotComeBack) + if (StuckDown(action.Kind, target, [.. cascade, .. ending?.Sharing ?? []]) is { Count: > 0 } cannotComeBack) { return Refuse(action, PlanProblemKind.CannotComeBack, cannotComeBack); } @@ -296,11 +296,14 @@ private static void AddSteps( /// Only where the answer is known. An unreadable start type is not a reason to /// refuse - that would turn missing information into a decision, which is the opposite of /// what the four read outcomes exist for. + /// + /// The neighbours of a forced restart as well, since 2026-09-29 (stability report W-6): a + /// disabled one running in the process dies with it and its way back is refused like anybody's. /// private static List StuckDown( - ActionKind kind, ScmEntry target, IReadOnlyList cascade) => + ActionKind kind, ScmEntry target, IReadOnlyList alongside) => kind is ActionKind.Restart or ActionKind.ForceRestart - ? [.. cascade + ? [.. alongside .Append(target) .Where(entry => entry.StartType is { IsPresent: true, Value: StartType.Disabled }) .Select(entry => entry.ServiceName)] @@ -479,10 +482,13 @@ private void AddWarnings( // Only where the answer is PRESENT. Absent means the entry is already stopped, where the // question does not arise - and turning that into a warning would put a sentence about a // 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)) { - var refusing = cascade - .Append(target) + IEnumerable asked = [.. cascade, target, .. ending?.Sharing ?? []]; + + var refusing = asked .Where(entry => entry.AcceptsStop is { IsPresent: true, Value: false }) .Select(entry => entry.ServiceName) .ToList(); @@ -510,7 +516,7 @@ private void AddWarnings( // neighbours keep running and here they do not. if (ending is { } dies) { - ForcedStop.AddWarnings(warnings, target, dies); + ForcedStop.AddWarnings(warnings, target, cascade, dies); } } diff --git a/src/Bws.Core/Planning/PlanRun.cs b/src/Bws.Core/Planning/PlanRun.cs index 53f0d91..dbe2fca 100644 --- a/src/Bws.Core/Planning/PlanRun.cs +++ b/src/Bws.Core/Planning/PlanRun.cs @@ -25,7 +25,7 @@ public enum StepOutcome /// /// Why a step was never attempted. /// -/// Three quite different stories, and folding them into one word would be the empty-value +/// Four quite different stories, and folding them into one word would be the empty-value /// mistake part 3 of 06-STRUKTURA-I-KONWENCJE is about: "skipped" alone cannot tell /// somebody whether the machine is where they wanted it or half-way to somewhere else. /// @@ -38,7 +38,20 @@ public enum SkipReason EarlierStepFailed, /// Somebody interrupted the run before this step was reached. - Cancelled + Cancelled, + + /// + /// A step asking a neighbour to stop, not needed because the process it lives in is not going to + /// be ended - the entry the process was to be ended for stopped by itself, cannot be read, or is + /// no longer in the process the plan named. + /// + /// A fourth story, added on the owner's decision of 2026-09-29, and neither of the others will + /// do. The neighbour is still running, so "already there" would be a claim about something + /// that is not true. And the step before it may well have worked - a polite stop that arrived is + /// the reason, not a failure. The neighbours are asked AFTER the entry itself since that day + /// (stability report W-2), so that a polite stop which works leaves them running. + /// + ProcessStays } /// @@ -167,8 +180,14 @@ [.. Results.Where(result => /// process was ended - has no later step of its own that watched it arrive, so it still reads as /// not arrived. Claiming otherwise would be a claim nobody checked. Reading the neighbours again /// after a terminate is a separate change and a backlog row, not a widening of this one. + /// + /// A neighbour skipped because the process stays is not counted at all (2026-09-29). The plan + /// wanted it down only on the way to ending the process, and that way was not needed - so a + /// forced stop whose polite step worked reads as done rather than as three neighbours "not where + /// you asked", which would be exit code 3 over a machine in exactly the state asked for. /// public bool Completed => Results + .Where(result => result.SkippedBecause != SkipReason.ProcessStays) .GroupBy(result => (result.Step.ServiceName, Aim(result.Step.Operation))) .All(same => same.Last().Arrived); diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs index 4e4f16c..236b18f 100644 --- a/src/Bws.Core/Planning/PlanRunner.cs +++ b/src/Bws.Core/Planning/PlanRunner.cs @@ -68,6 +68,7 @@ public PlanRun Run( var cancelled = false; var abandoned = false; var forwardFailed = false; + var cascadeFailed = false; for (var index = 0; index < plan.Steps.Count; index++) { @@ -76,28 +77,18 @@ public PlanRun Run( cancelled |= cancellation.IsCancellationRequested; abandoned |= abandonment.IsCancellationRequested; - // Putting things back is not part of the forward path and does not stop when the - // forward path does. Those steps exist to give back what earlier steps took, and - // abandoning them would leave the machine trimmed by a plan that failed - the - // one outcome nobody asked for. Anything that was never taken down is found - // already in place and reported as such, so this costs nothing when it is not - // needed. - var putsBack = step.Reason == StepReason.Restore; - - // THE STRONGER ATTEMPT STANDING BEHIND ONE THAT MAY NOT WORK, AND IT NEEDS THE OPPOSITE - // TREATMENT FROM THE LINE ABOVE. An escalation exists for the case where an earlier - // step did not arrive, so the ordinary rule - stop going forward once something failed - - // would skip the only step that was ever going to help. It is still held by an - // interruption, because that is somebody saying stop rather than something going wrong. - var stronger = step.Reason == StepReason.Escalation; - - if (abandoned - || (cancelled && !putsBack) - || (forwardFailed && !putsBack && !stronger)) + var because = Held(step.Reason, abandoned, cancelled, forwardFailed, cascadeFailed); + + if (because is null && step.Reason == StepReason.SharesTheProcess && Stays(plan)) + { + because = SkipReason.ProcessStays; + } + + if (because is { } skipped) { results.Add(Skipped( step, - cancelled || abandoned ? SkipReason.Cancelled : SkipReason.EarlierStepFailed, + skipped, EntryStatus.Unknown, // Nobody asked the manager about this entry, so there is nothing to say about @@ -114,9 +105,10 @@ public PlanRun Run( results.Add(result); - if (!result.Arrived && !putsBack) + if (!result.Arrived && step.Reason != StepReason.Restore) { forwardFailed = true; + cascadeFailed |= step.Reason == StepReason.Cascade; } } @@ -134,6 +126,76 @@ public PlanRun Run( }; } + /// + /// Why a step is not to be tried at all, from what has happened so far - or nothing when it is. + /// + /// Putting things back is not part of the forward path and does not stop when the forward path + /// does. Those steps exist to give back what earlier steps took, and abandoning them would + /// leave the machine trimmed by a plan that failed - the one outcome nobody asked for. Anything + /// that was never taken down is found already in place and reported as such, so this costs + /// nothing when it is not needed. + /// + /// THE STEPS STANDING BEHIND THE ENTRY'S OWN STOP NEED THE OPPOSITE TREATMENT. The ending + /// of a process and, since 2026-09-29, the neighbours asked on the way to it exist for the case + /// where the stop in front of them did not arrive - so the ordinary rule, stop going forward once + /// something failed, would skip the only steps that were ever going to help. A neighbour refusing + /// its own stop holds nothing back either: it dies with the process, and the preview says so. They + /// are still held by an interruption, because that is somebody saying stop rather than something + /// going wrong. + /// + /// AND BY A CASCADE STEP THAT DID NOT ARRIVE, on the owner's decision of 2026-09-29 (stability + /// report W-2). A dependant that refused to stop is still running on the process, and ending the + /// process under it is exactly what the manager's refusal was protecting. Until that day the ending + /// went ahead after any failure at all. + /// + private static SkipReason? Held( + StepReason reason, bool abandoned, bool cancelled, bool forwardFailed, bool cascadeFailed) + { + var putsBack = reason == StepReason.Restore; + var behind = reason is StepReason.Escalation or StepReason.SharesTheProcess; + + if (abandoned || (cancelled && !putsBack)) + { + return SkipReason.Cancelled; + } + + return (forwardFailed && !putsBack && !behind) || (cascadeFailed && behind) + ? SkipReason.EarlierStepFailed + : null; + } + + /// + /// Whether the process a plan ends is going to stay, asked just before a neighbour would be told + /// to stop on the way to ending it. + /// + /// The answer is what the ending step would find if it ran now - the entry it ends for + /// already stopped (the step would be "already there"), unreadable, or held by a process other + /// than the one the plan froze (the step would refuse). In each of the three nothing is going to be + /// ended, so asking a neighbour to stop would take down a service for no reason. The neighbours + /// come after the entry's own polite stop since 2026-09-29, and this is what lets that stop leave + /// them running when it works. + /// + /// One reading per neighbour, and it is not the runner working the plan out again. It + /// invents no step and changes none - it declines one that stopped being needed, the same way a + /// step whose entry is already where it was going is declined. A plan with no ending in it holds + /// no neighbours, and one that somehow did has no process to ask them to make way for. + /// + private bool Stays(OperationPlan plan) + { + if (plan.Steps.FirstOrDefault(step => step.Operation == StepOperation.Terminate) is not { } ending) + { + return true; + } + + // THE QUESTION END 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. + var holding = Holding(control.Read(ending.ServiceName)); + + return !(holding.IsPresent && holding.Value == ending.ProcessId); + } + private StepResult RunStep(PlanStep step, TimeSpan timeout) { // THE RULER RATHER THAN THE WALL CLOCK, SINCE 2026-09-03 - backlog 299. Everything below diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index d7d60cc..288250d 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -287,7 +287,11 @@ "gui.plan.problem.noProcessToEnd": "There is no process to end for {0}. Either the manager names none, which is ordinary for an entry that is not running, or the number it gave is not one a service process can have.", "gui.plan.problem.processCannotBeEnded": "Windows will not let this tool end process {1}, which is the process {0} runs in. It said: {2} (error {3}). Nothing was changed. A process the system protects is the usual reason, and no tool running as you gets past it.", "gui.plan.problem.processCannotBeEnded.plain": "Windows will not let this tool end process {1}, which is the process {0} runs in. Nothing was changed. A process the system protects is the usual reason, and no tool running as you gets past it.", - "gui.plan.problem.cascadeUnreadable":"Could not read everything that depends on {0}, so the list of what would go with its process would be shorter than the truth.", + "gui.plan.problem.dependentsInTheWay.one": "{1} depends on {0} and is still running. Ending the process under it is exactly what Windows refuses an ordinary stop for. Stop it first.", + "gui.plan.problem.dependentsInTheWay.many": "These depend on {0} and are still running: {1}. Ending the process under them is exactly what Windows refuses an ordinary stop for. Stop them first.", + "gui.plan.problem.neighbourNeeded.one": "{1} is running and depends on an entry that shares the process of {0}, which ending that process would take down. Stop {1} first.", + "gui.plan.problem.neighbourNeeded.many": "These are running and depend on an entry that shares the process of {0}, which ending that process would take down: {1}. Stop them first.", + "gui.plan.problem.cascadeUnreadable":"Could not read everything that depends on {0} or on what shares its process, so the list of what would go with its process would be shorter than the truth.", "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", diff --git a/src/Bws.Gui/ViewModels/PlanWords.cs b/src/Bws.Gui/ViewModels/PlanWords.cs index 5816605..5f2ef82 100644 --- a/src/Bws.Gui/ViewModels/PlanWords.cs +++ b/src/Bws.Gui/ViewModels/PlanWords.cs @@ -269,6 +269,15 @@ internal static IReadOnlyList Describe(IEnumerable warnings PlanProblemKind.CannotStartLate => Texts.Of("gui.plan.problem.cannotStartLate", problem.ServiceName, Listed(problem.Related)), + // The window has no way to ask for dependants, so both say to stop them first. + PlanProblemKind.DependentsInTheWay => problem.Related.Count == 1 + ? Texts.Of("gui.plan.problem.dependentsInTheWay.one", problem.ServiceName, Listed(problem.Related)) + : Texts.Of("gui.plan.problem.dependentsInTheWay.many", problem.ServiceName, Listed(problem.Related)), + + PlanProblemKind.NeighbourNeeded => problem.Related.Count == 1 + ? Texts.Of("gui.plan.problem.neighbourNeeded.one", problem.ServiceName, Listed(problem.Related)) + : Texts.Of("gui.plan.problem.neighbourNeeded.many", problem.ServiceName, Listed(problem.Related)), + _ => throw new ArgumentOutOfRangeException( nameof(problem), problem.Kind, EquivalentCommand.Unhandled) }; diff --git a/src/Bws.Gui/ViewModels/Planned.Forcing.cs b/src/Bws.Gui/ViewModels/Planned.Forcing.cs index ddef8cd..4b04054 100644 --- a/src/Bws.Gui/ViewModels/Planned.Forcing.cs +++ b/src/Bws.Gui/ViewModels/Planned.Forcing.cs @@ -78,6 +78,12 @@ internal sealed record Escalation(ActionKind Kind, string ServiceName, string La /// public sealed partial class Planned { + /// ERROR_DEPENDENT_SERVICES_RUNNING - the manager's refusal to stop what something running needs. + private const int DependentsStillRunning = 1051; + + /// ERROR_ACCESS_DENIED. + private const int AccessDenied = 5; + private string _typed = string.Empty; /// Why this sheet is open, when something offered it. Empty for every other plan. @@ -293,12 +299,20 @@ or PlanWarningKind.CriticalService /// escalation standing behind it in the same plan - offering another would propose a second /// sheet identical to the one already on screen. And an ask with no stop in it cannot arrive /// here at all, which answers by having no arm for it. + /// + /// The sixth arrived with the external stability report (W-4), on the owner's decision of + /// 2026-09-29: no offer under the two refusals ending a process does not answer. Error 1051 is + /// the manager refusing because something running depends on the entry - the forced plan behind + /// the offer refuses that since the same day, so the button would open an apology, and until then + /// it ended the process under the dependants. Error 5 is a refusal of rights, and a stronger ask + /// does not bring rights with it. /// private static Escalation? Offered(PlanRun run, StepResult result) { if (result.Step.Operation != StepOperation.Stop || result.Status == EntryStatus.Stopped || !result.ProcessId.IsPresent + || result.ErrorCode is DependentsStillRunning or AccessDenied || Forcing(run.Plan.Action.Kind) is not { } kind) { return null; diff --git a/tests/Bws.Cli.Tests/ArgumentRefusalTests.cs b/tests/Bws.Cli.Tests/ArgumentRefusalTests.cs index d18a160..f622063 100644 --- a/tests/Bws.Cli.Tests/ArgumentRefusalTests.cs +++ b/tests/Bws.Cli.Tests/ArgumentRefusalTests.cs @@ -32,6 +32,19 @@ public void A_comparison_with_three_sides_is_answered_before_the_manager_is_open Assert.Equal(ExitCode.Usage, Answer("snapshot", "diff", "before.json", "after.json", "--live")); } + /// + /// --force skips every stop in front of the ending, the cascade's too, so beside --dependents it + /// promised dependants stopped and ended the process under them - stability report W-4, a preview + /// disagreeing with its run. Refused together, and each alone still reaches the machine. + /// + [Fact] + public void Force_and_dependents_on_kill_are_refused_together_and_not_apart() + { + Assert.Equal(ExitCode.Usage, Answer("kill", "Spooler", "--force", "--dependents")); + Assert.Null(Answer("kill", "Spooler", "--force")); + Assert.Null(Answer("kill", "Spooler", "--dependents")); + } + [Fact] public void A_comparison_with_two_files_has_nothing_wrong_with_it() { diff --git a/tests/Bws.Core.Tests/EndingRightsTests.cs b/tests/Bws.Core.Tests/EndingRightsTests.cs index 0b3994c..6c609ac 100644 --- a/tests/Bws.Core.Tests/EndingRightsTests.cs +++ b/tests/Bws.Core.Tests/EndingRightsTests.cs @@ -175,28 +175,33 @@ public void A_stop_that_ends_nothing_asks_nothing() } /// - /// The chain with every entry in a process of its own, which is what 105 of 110 processes on a - /// real machine look like. + /// MRxSmb20 alone in its process, which is what 105 of 110 processes on a real machine look like. + /// + /// Its three dependants are stopped, and until 2026-09-29 they were running. A forced stop + /// refuses running dependants since that day (stability report W-4), and every test here is about + /// the identity of the process rather than about who depends on it - ForcedStopRefusalTests holds + /// the refusal. /// - private static FakeScmCatalog Alone() => WithProcesses( - ("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 6666), ("Netlogon", 7777)); - - /// Two entries in one process, which is what an svchost group looks like. - private static FakeScmCatalog Sharing() => WithProcesses( - ("MRxSmb20", 4444), ("LanmanWorkstation", 4444), ("SessionEnv", 6666), ("Netlogon", 7777)); - - private static FakeScmCatalog WithProcesses(params (string Name, int ProcessId)[] processes) + private static FakeScmCatalog Alone() { - var catalog = Chain(); + var catalog = Housed(("MRxSmb20", 4444)); - foreach (var (name, processId) in processes) + foreach (var name in (string[])["LanmanWorkstation", "SessionEnv", "Netlogon"]) { - catalog = Rebuild(catalog, name, entry => entry with { ProcessId = Reading.Present(processId) }); + catalog = Rebuild(catalog, name, entry => entry with + { + Status = EntryStatus.Stopped, + ProcessId = Reading.Absent() + }); } return catalog; } + /// Two entries in one process, which is what an svchost group looks like. + private static FakeScmCatalog Sharing() => Housed( + ("MRxSmb20", 4444), ("LanmanWorkstation", 4444), ("SessionEnv", 6666), ("Netlogon", 7777)); + private static OperationPlan Plan( FakeScmCatalog catalog, FakeEndingFacts facts, bool immediate = false) => new PlanBuilder(catalog.ReadAll(), catalog, facts) diff --git a/tests/Bws.Core.Tests/EquivalentCommandTests.cs b/tests/Bws.Core.Tests/EquivalentCommandTests.cs index 5f4e170..9bc4e6b 100644 --- a/tests/Bws.Core.Tests/EquivalentCommandTests.cs +++ b/tests/Bws.Core.Tests/EquivalentCommandTests.cs @@ -41,6 +41,23 @@ public void Asking_for_the_cascade_adds_the_switch_that_asks_for_it() EquivalentCommand.For(new ServiceAction(ActionKind.Restart, "Spooler", IncludeDependents: true))); } + /// + /// A forced stop planned with its dependants hands back the switch it was planned with - stability + /// report W-12. Without it the pasted line plans the stop alone, which since 2026-09-29 is a + /// refusal rather than the same plan. + /// + [Fact] + public void A_forced_stop_with_its_dependants_hands_back_the_switch_it_was_planned_with() + { + Assert.Equal( + "bws kill Spooler --dependents", + EquivalentCommand.For(new ServiceAction(ActionKind.ForceStop, "Spooler", IncludeDependents: true))); + + Assert.Equal( + "bws kill Spooler --dependents --restart", + EquivalentCommand.For(new ServiceAction(ActionKind.ForceRestart, "Spooler", IncludeDependents: true))); + } + /// /// A REAL FAULT THIS CLASS HAD, CAUGHT BEFORE IT REACHED THE WINDOW. /// diff --git a/tests/Bws.Core.Tests/Fakes/DependencyChain.cs b/tests/Bws.Core.Tests/Fakes/DependencyChain.cs index 57e230b..cd463ce 100644 --- a/tests/Bws.Core.Tests/Fakes/DependencyChain.cs +++ b/tests/Bws.Core.Tests/Fakes/DependencyChain.cs @@ -74,6 +74,23 @@ internal static FakeScmCatalog Rebuild( .DependedOnBy("LanmanWorkstation", "SessionEnv", "Netlogon"); } + /// + /// The chain with each entry in the process named beside it - two entries given one number share + /// it, which is what an svchost group looks like. Out of ForcedStopTests on 2026-09-29, when a + /// second file of forced stop tests needed the same machine. + /// + internal static FakeScmCatalog Housed(params (string Name, int ProcessId)[] processes) + { + var catalog = Chain(); + + foreach (var (name, processId) in processes) + { + catalog = Rebuild(catalog, name, entry => entry with { ProcessId = Reading.Present(processId) }); + } + + return catalog; + } + /// /// Builds with the cascade included, which is what most of these are about. The plain /// form, where it is not, has tests of its own. diff --git a/tests/Bws.Core.Tests/ForcedRunTests.cs b/tests/Bws.Core.Tests/ForcedRunTests.cs new file mode 100644 index 0000000..2647f63 --- /dev/null +++ b/tests/Bws.Core.Tests/ForcedRunTests.cs @@ -0,0 +1,193 @@ +using Bws.Core.Planning; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// How a plan that ends a process is carried out when something on the way does not go to plan - +/// the external stability report of 2026-09-29 (W-2) and the owner's decision on it. +/// +/// Three rules, each the opposite of what the run did before that day. A dependant that will +/// not stop holds the ending, because it is still running on the process. A neighbour that will not +/// stop holds nothing, because it dies with the process either way and the preview says so. And a +/// neighbour is asked only on the way to an ending that is going to happen - an entry that stops +/// politely leaves its process, and its neighbours, where they are. +/// +/// And what the way back says about the neighbours afterwards (W-10), because the ending moves +/// them without a step of their own that did. +/// +/// Driven through plans written out by hand, like EscalationRunTests, because these are about +/// what the RUNNER does with the steps it is handed. The builder's order has tests of its own. +/// +public sealed class ForcedRunTests +{ + private const int Held = 4812; + + [Fact] + public void A_dependant_that_will_not_stop_holds_the_ending() + { + var control = new FakeScmControl().RefusingRequests("Dependant", 1052); + + var run = Run(control, Forced( + Stop("Dependant", StepReason.Cascade), + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + Assert.Equal(StepOutcome.Failed, run.Results[0].Outcome); + Assert.All(run.Results.Skip(1), result => Assert.Equal(SkipReason.EarlierStepFailed, result.SkippedBecause)); + + // The assertion with the weight: nothing was ended under a service still running on it. + Assert.Empty(control.Ended); + Assert.Equal(["Dependant"], control.Requested); + } + + [Fact] + public void A_neighbour_that_will_not_stop_holds_nothing_back() + { + var control = new FakeScmControl() + .Reaching("Spooler", new ServiceProgress(EntryStatus.StopPending, 0, TimeSpan.Zero, Held)) + .RefusingRequests("Housemate", 1052); + + var run = Run(control, Forced( + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + Assert.Equal( + [StepOutcome.TimedOut, StepOutcome.Failed, StepOutcome.Succeeded], + run.Results.Select(result => result.Outcome)); + + Assert.Equal(Held, Assert.Single(control.Ended)); + } + + [Fact] + public void Neighbours_stay_running_when_the_entry_stops_by_itself() + { + var control = new FakeScmControl(); + + var run = Run(control, Forced( + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + Assert.Equal(SkipReason.ProcessStays, run.Results[1].SkippedBecause); + Assert.Equal(SkipReason.AlreadyThere, run.Results[2].SkippedBecause); + Assert.Equal(["Spooler"], control.Requested); + Assert.Empty(control.Ended); + + // A neighbour the plan did not need is not "not where you asked" - exit code 3 over a machine + // standing exactly where somebody wanted it would be a runbook line nobody trusts again. + Assert.True(run.Completed); + } + + [Fact] + public void A_neighbour_is_left_alone_when_the_entry_cannot_be_read() + { + // The ending cannot happen - its own reading will be refused - so stopping a neighbour on the + // way to it would take a service down for nothing. + var control = new FakeScmControl().RefusingReads("Spooler", 5); + + var run = Run(control, Forced( + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + Assert.Equal(SkipReason.ProcessStays, run.Results[1].SkippedBecause); + Assert.Empty(control.Requested); + Assert.Empty(control.Ended); + } + + [Fact] + public void A_neighbour_is_left_alone_when_the_process_is_not_the_one_the_plan_named() + { + var control = new FakeScmControl() + .Reaching("Spooler", new ServiceProgress(EntryStatus.StopPending, 0, TimeSpan.Zero, 9999)); + + var run = Run(control, Forced( + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + Assert.Equal(SkipReason.ProcessStays, run.Results[1].SkippedBecause); + Assert.Equal(StepOutcome.Failed, run.Results[2].Outcome); + Assert.Equal(["Spooler"], control.Requested); + Assert.Empty(control.Ended); + } + + /// + /// The way back names a neighbour the ending took down - stability report W-10. Its own step was + /// refused, so nothing but the ending moved it, and until 2026-09-29 it had no line at all. + /// + [Fact] + public void A_neighbour_that_died_with_the_process_is_handed_back() + { + var control = new FakeScmControl() + .Reaching("Spooler", new ServiceProgress(EntryStatus.StopPending, 0, TimeSpan.Zero, Held)) + .RefusingRequests("Housemate", 1052); + + var run = Run(control, Forced( + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + Assert.Equal( + ["Housemate", "Spooler"], + run.Reversal.Where(back => back.Operation == StepOperation.Start).Select(back => back.ServiceName).Order(StringComparer.Ordinal)); + } + + /// + /// A forced restart that skipped the courtesy brings a neighbour back that nobody asked to stop - + /// and the way back used to say "stop it", worked out from the one step it could see, about a + /// service that had been running all along. + /// + [Fact] + public void A_forced_restart_without_the_courtesy_hands_back_nothing_for_a_neighbour_it_brought_back() + { + // Named to the double before the ending, so that it lives in the process being ended - the + // double only takes down entries it has already heard of. + var control = new FakeScmControl().At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced( + new PlanStep("Spooler", "Spooler", StepOperation.Terminate, StepReason.Requested, ProcessId: Held, TakesWithIt: ["Housemate"]), + Start("Spooler"), + Start("Housemate"))); + + Assert.Equal(Held, Assert.Single(control.Ended)); + Assert.Empty(run.Reversal); + } + + [Fact] + public void A_neighbour_found_already_stopped_is_not_handed_back() + { + // Its own step found it stopped, so it was not in the process when the process ended. + var control = new FakeScmControl() + .Reaching("Spooler", new ServiceProgress(EntryStatus.StopPending, 0, TimeSpan.Zero, Held)) + .At("Housemate", EntryStatus.Stopped); + + var run = Run(control, Forced( + Stop("Spooler", StepReason.Requested), + Stop("Housemate", StepReason.SharesTheProcess), + Ending("Housemate"))); + + 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); + + private static PlanStep Ending(params string[] housemates) => + new("Spooler", "Spooler", StepOperation.Terminate, StepReason.Escalation, ProcessId: Held, TakesWithIt: housemates); + + private static OperationPlan Forced(params PlanStep[] steps) => new() + { + Action = new ServiceAction(ActionKind.ForceStop, "Spooler"), + Steps = steps, + Warnings = [], + Problems = [] + }; + + private static PlanRun Run(FakeScmControl control, OperationPlan plan) => + new PlanRunner(control, new FakeClock()).Run(plan, TimeSpan.FromSeconds(30)); +} diff --git a/tests/Bws.Core.Tests/ForcedStopRefusalTests.cs b/tests/Bws.Core.Tests/ForcedStopRefusalTests.cs new file mode 100644 index 0000000..3cae243 --- /dev/null +++ b/tests/Bws.Core.Tests/ForcedStopRefusalTests.cs @@ -0,0 +1,173 @@ +using Bws.Core.Planning; +using Bws.Core.Tests.Fakes; + +using static Bws.Core.Tests.Fakes.DependencyChain; + +namespace Bws.Core.Tests; + +/// +/// What a plan that ends a process refuses, and whom it asks about, since the external stability +/// report of 2026-09-29 (W-4 and W-6). +/// +/// Every test here is a preview that used to promise less than the run would do. A forced +/// stop ended the process under running dependants, named no critical entry arriving with the +/// cascade, and never asked about the neighbours at all - whether anything needed them, whether they +/// would take a stop, whether a restart could bring them back. The owner's decision was a refusal +/// wherever something running would be left standing on a process that is gone. +/// +/// The machine is the measured chain - MRxSmb20 needed by LanmanWorkstation, which is needed by +/// SessionEnv and Netlogon - with the processes moved around per test. Netlogon sits at the end of it, +/// so nothing depends on it. +/// +public sealed class ForcedStopRefusalTests +{ + [Fact] + public void A_forced_stop_refuses_while_entries_depending_on_it_run_outside_the_plan() + { + // The manager refuses an ordinary stop here with 1051 and nothing is harmed. The last step of + // this plan asks no manager, so the same situation was a process ended under three services. + var plan = Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: false, Separate()); + + var problem = Assert.Single(plan.Problems); + + Assert.Equal(PlanProblemKind.DependentsInTheWay, problem.Kind); + Assert.Equal(["LanmanWorkstation", "Netlogon", "SessionEnv"], problem.Related.Order(StringComparer.Ordinal)); + Assert.Empty(plan.Steps); + } + + [Fact] + public void With_its_dependants_asked_for_they_are_stopped_first_and_the_warning_names_exactly_those() + { + var plan = Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: true, Separate()); + + string[] stopped = [.. plan.Steps.Where(step => step.Reason == StepReason.Cascade).Select(step => step.ServiceName)]; + + Assert.Equal(3, stopped.Length); + + // THE WARNING AND THE STEPS ARE ONE FACT. Until 2026-09-29 the warning was raised from the + // cascade and the steps were left out under --force, so the preview said three entries would + // stop and the run stopped none. + Assert.Equal(stopped, Warning(plan, PlanWarningKind.Cascade).Related); + Assert.Equal(StepOperation.Terminate, plan.Steps[^1].Operation); + } + + [Fact] + public void Skipping_the_courtesy_cannot_carry_the_dependants() + { + // Loud rather than a refusal a person can meet: the command line turns the pair back before + // anything is read, and the window never asks for dependants. + var catalog = Separate(); + var builder = new PlanBuilder(catalog.ReadAll(), catalog); + + Assert.Throws(() => builder.Build( + new ServiceAction(ActionKind.ForceStop, "MRxSmb20", IncludeDependents: true, Immediate: true))); + } + + [Fact] + public void A_running_entry_that_needs_a_neighbour_is_a_refusal() + { + // Netlogon shares its process with LanmanWorkstation here, and SessionEnv - running, and in no + // step of this plan - needs LanmanWorkstation. Ending the process pulls it out from under + // SessionEnv, and --dependents would not help: SessionEnv does not depend on Netlogon. + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, Neighboured()); + + var problem = Assert.Single(plan.Problems); + + Assert.Equal(PlanProblemKind.NeighbourNeeded, problem.Kind); + Assert.Equal("SessionEnv", Assert.Single(problem.Related)); + } + + [Fact] + public void A_neighbour_whose_dependants_cannot_be_read_is_a_casualty_list_known_to_be_short() + { + var catalog = Neighboured(); + catalog.RefuseDependentsFor.Add("LanmanWorkstation"); + + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, catalog); + + Assert.Equal(PlanProblemKind.CascadeUnreadable, Assert.Single(plan.Problems).Kind); + } + + [Fact] + public void A_critical_entry_arriving_with_the_cascade_is_named_on_a_forced_stop() + { + // The ordinary stop names it through CriticalEntries, which does not run for this kind, and + // the forcing kind asked only the neighbours and the target until 2026-09-29. + var catalog = new FakeScmCatalog( + [ + Running("Target", "Target") with { ProcessId = Reading.Present(4444) }, + Running("RpcSs", "Remote Procedure Call") with { ProcessId = Reading.Present(5555) } + ]) + .DependedOnBy("Target", "RpcSs"); + + var plan = Plan(ActionKind.ForceStop, "Target", includeDependents: true, catalog); + + Assert.Contains("RpcSs", Warning(plan, PlanWarningKind.CriticalService).Related); + } + + [Fact] + public void A_disabled_neighbour_of_a_forced_restart_could_not_come_back() + { + var catalog = Rebuild( + Sharing(), + "SessionEnv", + entry => entry with { StartType = Reading.Present(Core.StartType.Disabled) }); + + var problem = Assert.Single( + new PlanBuilder(catalog.ReadAll(), catalog).Build(new ServiceAction(ActionKind.ForceRestart, "Netlogon")).Problems); + + Assert.Equal(PlanProblemKind.CannotComeBack, problem.Kind); + Assert.Equal("SessionEnv", Assert.Single(problem.Related)); + } + + [Fact] + public void A_neighbour_that_will_not_take_a_stop_is_named_before_anything_runs() + { + var catalog = Rebuild(Sharing(), "SessionEnv", entry => entry with { AcceptsStop = Reading.Present(false) }); + + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, catalog); + + Assert.Contains("SessionEnv", Warning(plan, PlanWarningKind.DoesNotAcceptStop).Related); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void The_ending_names_the_neighbours_it_takes_with_it(bool immediate) + { + // With --force the neighbours have no steps of their own, and this list is the only thing a + // way back can learn them from. + var catalog = Sharing(); + + var plan = new PlanBuilder(catalog.ReadAll(), catalog) + .Build(new ServiceAction(ActionKind.ForceStop, "Netlogon", Immediate: immediate)); + + var ending = Assert.Single(plan.Steps, step => step.Operation == StepOperation.Terminate); + + Assert.Equal(["SessionEnv"], ending.TakesWithIt!); + } + + [Fact] + public void The_entry_is_asked_before_its_neighbours_and_the_neighbours_before_the_ending() + { + // Until 2026-09-29 the neighbours came first, and a neighbour refusing its stop skipped the + // polite stop of the entry itself - the process was ended without the entry ever being asked. + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, Sharing()); + + Assert.Equal( + [StepReason.Requested, StepReason.SharesTheProcess, StepReason.Escalation], + plan.Steps.Select(step => step.Reason)); + } + + /// Every entry in a process of its own. + private static FakeScmCatalog Separate() => + Housed(("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 6666), ("Netlogon", 7777)); + + /// Netlogon and SessionEnv in one process, and neither needs the other. + private static FakeScmCatalog Sharing() => + Housed(("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 7777), ("Netlogon", 7777)); + + /// Netlogon and LanmanWorkstation in one process, with SessionEnv needing the second. + private static FakeScmCatalog Neighboured() => + Housed(("MRxSmb20", 4444), ("LanmanWorkstation", 7777), ("SessionEnv", 6666), ("Netlogon", 7777)); +} diff --git a/tests/Bws.Core.Tests/ForcedStopTests.cs b/tests/Bws.Core.Tests/ForcedStopTests.cs index 66b59e9..4158d9d 100644 --- a/tests/Bws.Core.Tests/ForcedStopTests.cs +++ b/tests/Bws.Core.Tests/ForcedStopTests.cs @@ -22,7 +22,7 @@ public sealed class ForcedStopTests [Fact] public void A_forced_stop_asks_politely_first_and_ends_the_process_only_behind_that() { - var plan = Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: false, Alone()); + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, Alone()); Assert.Collection( plan.Steps, @@ -41,7 +41,7 @@ public void A_forced_stop_asks_politely_first_and_ends_the_process_only_behind_t // NAMED IN THE PLAN, WHICH IS THE HALF THE WORD "terminate" CANNOT CARRY. The entry // is a service and what ends is a process, and the two are not the same thing. - Assert.Equal(4444, step.ProcessId); + Assert.Equal(7777, step.ProcessId); }); } @@ -67,9 +67,9 @@ public void An_entry_already_asked_to_stop_is_not_asked_again() // were walked. The window reaches this plan only after a stop gave up, so the entry is // sitting in a pending state with a request already in flight. A second polite step would // send the same thing again and then wait the whole ceiling for it. - var catalog = Rebuild(Alone(), "MRxSmb20", entry => entry with { Status = EntryStatus.StopPending }); + var catalog = Rebuild(Alone(), "Netlogon", entry => entry with { Status = EntryStatus.StopPending }); - var only = Assert.Single(Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: false, catalog).Steps); + var only = Assert.Single(Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, catalog).Steps); Assert.Equal(StepOperation.Terminate, only.Operation); } @@ -77,9 +77,9 @@ public void An_entry_already_asked_to_stop_is_not_asked_again() [Fact] public void Entries_living_in_the_same_process_are_steps_with_a_reason_of_their_own() { - var plan = Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: false, Sharing()); + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, Sharing()); - var neighbour = plan.Steps.First(step => step.ServiceName == "LanmanWorkstation"); + var neighbour = plan.Steps.First(step => step.ServiceName == "SessionEnv"); Assert.Equal(StepOperation.Stop, neighbour.Operation); @@ -102,10 +102,10 @@ public void Entries_living_in_the_same_process_are_steps_with_a_reason_of_their_ public void What_dies_alongside_is_said_as_well_as_stepped() { var warning = Warning( - Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: false, Sharing()), + Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, Sharing()), PlanWarningKind.TerminationTakesWithIt); - Assert.Equal("LanmanWorkstation", Assert.Single(warning.Related)); + Assert.Equal("SessionEnv", Assert.Single(warning.Related)); } [Fact] @@ -114,7 +114,7 @@ public void The_shared_process_warning_does_not_also_appear_and_contradict_it() // That one says the process does not go away and the neighbours keep running. True of an // ordinary stop, and the exact opposite here - two warnings disagreeing about one machine // on one screen would be worse than either alone. - var plan = Plan(ActionKind.ForceStop, "MRxSmb20", includeDependents: false, Sharing()); + var plan = Plan(ActionKind.ForceStop, "Netlogon", includeDependents: false, Sharing()); Assert.DoesNotContain(plan.Warnings, warning => warning.Kind == PlanWarningKind.SharedProcess); } @@ -193,7 +193,7 @@ public void A_forced_restart_brings_back_everything_it_took_down_including_the_h // The entry somebody asked about, and the one that only died because it shared the process. // Nothing works this out afterwards from what happened - it is decided when the plan is // built, which is the reason the housemates are steps in the first place. - Assert.Equal(["MRxSmb20", "LanmanWorkstation"], started); + Assert.Equal(["Netlogon", "SessionEnv"], started); Assert.All(plan.Steps.Where(step => step.Operation == StepOperation.Start), step => Assert.Equal(StepReason.Restore, step.Reason)); } @@ -201,27 +201,20 @@ public void A_forced_restart_brings_back_everything_it_took_down_including_the_h /// /// The chain with every entry in a process of its own, which is what 105 of 110 processes on a /// real machine look like. + /// + /// The entry forced here is Netlogon, at the end of the chain, and until 2026-09-29 it was + /// MRxSmb20 at the head of it. Three running entries depend on MRxSmb20, and a forced stop + /// refuses running dependants since that day (stability report W-4) - so every test below that is + /// about something else would have met that refusal first. ForcedStopRefusalTests holds it. /// - private static FakeScmCatalog Alone() => WithProcesses( + private static FakeScmCatalog Alone() => Housed( ("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 6666), ("Netlogon", 7777)); /// Two entries in one process, which is what an svchost group looks like. - private static FakeScmCatalog Sharing() => WithProcesses( - ("MRxSmb20", 4444), ("LanmanWorkstation", 4444), ("SessionEnv", 6666), ("Netlogon", 7777)); - - private static FakeScmCatalog WithProcesses(params (string Name, int ProcessId)[] processes) - { - var catalog = Chain(); - - foreach (var (name, processId) in processes) - { - catalog = Rebuild(catalog, name, entry => entry with { ProcessId = Reading.Present(processId) }); - } - - return catalog; - } + private static FakeScmCatalog Sharing() => Housed( + ("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 7777), ("Netlogon", 7777)); private static OperationPlan Build(ActionKind kind, bool immediate, FakeScmCatalog catalog) => new PlanBuilder(catalog.ReadAll(), catalog) - .Build(new ServiceAction(kind, "MRxSmb20", IncludeDependents: false, Immediate: immediate)); + .Build(new ServiceAction(kind, "Netlogon", IncludeDependents: false, Immediate: immediate)); } diff --git a/tests/Bws.Gui.Tests/CountedWords.cs b/tests/Bws.Gui.Tests/CountedWords.cs index 48603da..bb3f680 100644 --- a/tests/Bws.Gui.Tests/CountedWords.cs +++ b/tests/Bws.Gui.Tests/CountedWords.cs @@ -99,6 +99,10 @@ internal static class CountedWords // order group {1}" puts a service name before the verb, and "could not read some of these // fields: {0} Press F5" puts a list of fields before the start of the next sentence. "belongs", "Press", + // One more on 2026-09-29, from the refusal of a forced stop under running dependants: "{1} + // depends on {0} and is still running" is the ONE half of a pair, and the placeholder before the + // verb is a service name. The MANY half starts "These depend on", so no count ever stands here. + "depends", // Not verbs, and the reason no shape can do this job. A unit, a determiner and a singular // noun that happens to end in s. "ms", "this", "process" diff --git a/tests/Bws.Gui.Tests/ForcedStopGuards.cs b/tests/Bws.Gui.Tests/ForcedStopGuards.cs index 6e48917..0486ebb 100644 --- a/tests/Bws.Gui.Tests/ForcedStopGuards.cs +++ b/tests/Bws.Gui.Tests/ForcedStopGuards.cs @@ -20,6 +20,13 @@ namespace Bws.Gui.Tests; /// public sealed class ForcedStopGuards { + /// + /// ERROR_INVALID_SERVICE_CONTROL - the manager refusing a control the entry does not accept, which + /// is what a failed step here stands for unless a test says otherwise. It was 1051 until + /// 2026-09-29, when 1051 stopped being a refusal that offers anything. + /// + private const int InvalidControl = 1052; + /// /// A stop that ran out of time on an entry still held by a process is the case the whole slice /// exists for. @@ -56,6 +63,27 @@ public void A_stop_the_manager_refused_offers_it_as_well() Assert.True(Assert.Single(panel.Failures).HasOffer); } + /// + /// No offer under the two refusals a stronger ask does not answer - the external stability + /// report (W-4) and the owner's decision of 2026-09-29. + /// + /// 1051 is something running that depends on the entry. The forced plan behind the offer + /// refuses that since the same day, so the button would open an apology - and until then it ended + /// the process under the dependants. 5 is a refusal of rights, which a stronger ask does not + /// bring with it. + /// + [Theory] + [InlineData(1051)] + [InlineData(5)] + public void A_stop_refused_for_a_reason_forcing_does_not_answer_offers_nothing(int code) + { + var panel = Showing(Asking(ActionKind.Stop)); + + panel.Finished(Ended(panel, StepOutcome.Failed, refusedWith: code)); + + Assert.False(Assert.Single(panel.Failures).HasOffer); + } + /// /// No process, no offer - the condition easiest to leave out and the one that would send /// somebody to a sheet the core refuses to build. @@ -417,7 +445,8 @@ private static BulkRun Ended( Planned panel, StepOutcome outcome, Reading? process = null, - EntryStatus status = EntryStatus.Running) => new() + EntryStatus status = EntryStatus.Running, + int refusedWith = InvalidControl) => new() { Plan = panel.Plan!, Runs = @@ -434,7 +463,7 @@ private static BulkRun Ended( SkippedBecause = null, Status = status, ProcessId = process ?? Reading.Present(4812), - ErrorCode = outcome == StepOutcome.Failed ? 1051 : 0, + ErrorCode = outcome == StepOutcome.Failed ? refusedWith : 0, Error = outcome == StepOutcome.Failed ? "A stop control has been refused." : null, Milliseconds = 10 })