diff --git a/CHANGELOG.md b/CHANGELOG.md index c07ea92..f8f1b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,20 @@ is not part of this repository. - The window's reason for offering Force stop after a stop that was given up on no longer names a number of seconds that could be wrong. The line under a step being waited for says the limit is about progress. +- A force stop is refused when the process is one Windows marks critical, or when a service living + in it has "restart the computer" among its recovery actions - ending such a process takes the whole + machine down. It is refused as well when those recovery actions cannot be read. +- The preview of a force stop says when Windows will start a service living in the process again by + itself once the process is ended, when it will run a program named in a service's recovery + actions, and when a service has a recovery action of a kind the tool cannot name. In the JSON of a + plan these are the warnings `recoveryRestarts`, `recoveryRunsProgram` and `recoveryUnnamed`. Until + now `bws kill` reported such a service stopped while Windows was already starting it again. +- A force stop whose service Windows starts again at once is reported straight away as the process + ended and the service running again, with its new process, instead of after the whole limit as + having run out of time. In the JSON of a run that step is `"outcome": "failed"` with `errorCode` 0. +- Just before the process is ended, a force stop looks at it once more. If a service has started + inside it since the preview, or a running service outside it has started to depend on something + inside it, the process is not ended and the step says why. ## [0.3.0] - 2026-09-25 diff --git a/README.md b/README.md index 5f790b3..954e475 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,12 @@ line that asks for the same thing, and the way back afterwards. 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. +- **Force stop says what Windows does once the process is gone.** A process Windows marks critical, + or a service whose recovery actions restart the computer, is refused - ending it takes the whole + machine down. A service Windows starts again by itself, or one whose recovery runs a program, is + named in the preview, because the stop may not last. Just before the process is ended the tool + looks at it once more, and ends nothing if a service moved into it or started depending on it + since the preview. - **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.Aftermath.cs b/src/Bws.Cli/PlanText.Aftermath.cs new file mode 100644 index 0000000..4eb4cb9 --- /dev/null +++ b/src/Bws.Cli/PlanText.Aftermath.cs @@ -0,0 +1,59 @@ +using Bws.Core.Planning; + +namespace Bws.Cli; + +/// +/// What a plan that ends a process says about what comes after it - three warnings and three refusals, +/// since 2026-09-30 (stability report W-3, package B2). +/// +/// Its own file, reached from the discard arm of the two switches beside it, so that neither grows: +/// the window's twin of the warning switch stands one fork under the complexity ceiling, and this side is +/// cut the same way so the two stay one shape. The named arms and the refusal at the end are the rule +/// those switches have kept since 2026-09-06 - a kind with no sentence throws rather than borrowing one. +/// +internal static partial class PlanText +{ + private static string Aftermath(PlanWarning warning) => warning.Kind switch + { + PlanWarningKind.RecoveryRestarts => Texts.Of( + Count("cli.plan.warning.recoveryRestarts", warning), + warning.ServiceName, warning.Related.Count, Join(warning.Related)), + + PlanWarningKind.RecoveryRunsProgram => Texts.Of( + Count("cli.plan.warning.recoveryRunsProgram", warning), + warning.ServiceName, warning.Related.Count, Join(warning.Related)), + + PlanWarningKind.RecoveryUnnamed => Texts.Of( + Count("cli.plan.warning.recoveryUnnamed", warning), + warning.ServiceName, warning.Related.Count, Join(warning.Related)), + + _ => throw new ArgumentOutOfRangeException( + nameof(warning), warning.Kind, EquivalentCommand.Unhandled) + }; + + /// + /// 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. + /// + private static string Aftermath(PlanProblem problem) => problem.Kind switch + { + PlanProblemKind.ProcessIsCritical => Texts.Of("cli.plan.problem.processIsCritical", problem.ServiceName), + + PlanProblemKind.RecoveryRestartsComputer => Texts.Of( + problem.Related.Count == 1 + ? "cli.plan.problem.recoveryRestartsComputer.one" + : "cli.plan.problem.recoveryRestartsComputer.many", + problem.ServiceName, Join(problem.Related)), + + PlanProblemKind.AftermathUnreadable => Texts.Of( + problem.Related.Count == 0 + ? "cli.plan.problem.aftermathUnreadable.process" + : problem.Related.Count == 1 + ? "cli.plan.problem.aftermathUnreadable.one" + : "cli.plan.problem.aftermathUnreadable.many", + problem.ServiceName, Join(problem.Related)), + + _ => throw new ArgumentOutOfRangeException( + nameof(problem), problem.Kind, EquivalentCommand.Unhandled) + }; +} diff --git a/src/Bws.Cli/PlanText.Warnings.cs b/src/Bws.Cli/PlanText.Warnings.cs index cbd6a01..3f3cb58 100644 --- a/src/Bws.Cli/PlanText.Warnings.cs +++ b/src/Bws.Cli/PlanText.Warnings.cs @@ -90,8 +90,8 @@ internal static partial class PlanText // kind but one used to fall through to "is already in that state, so nothing would change" - // so a warning added without a sentence would not have been silent, which is survivable, but // would have said something confident and wrong about a machine, which is not. The window's - // own switch had the same shape and was changed the same day. - _ => throw new ArgumentOutOfRangeException( - nameof(warning), warning.Kind, EquivalentCommand.Unhandled) + // own switch had the same shape and was changed the same day. Since 2026-09-30 the refusal + // stands at the end of the next switch along, which names what an ending sets off. + _ => Aftermath(warning) }; } diff --git a/src/Bws.Cli/PlanText.cs b/src/Bws.Cli/PlanText.cs index f642bef..ade59db 100644 --- a/src/Bws.Cli/PlanText.cs +++ b/src/Bws.Cli/PlanText.cs @@ -149,7 +149,11 @@ private static string Render( // 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 // request and the SERVICE stopped - so it says that, with the service's own exit code - // (stability report W-7, 2026-09-30). + // (stability report W-7, 2026-09-30). And an ending the manager undid at once is not a refusal + // either - the process died, and the sentence says that first (W-3, the same day). + StepOutcome.Failed when result.StartedAgain => Texts.Of( + "cli.run.outcome.startedAgain", result.ProcessId.Value, Took(result.Milliseconds)), + StepOutcome.Failed => result.StoppedWhileStarting ? Texts.Of( "cli.run.outcome.stoppedWhileStarting", result.Error!, result.ErrorCode, Took(result.Milliseconds)) @@ -257,8 +261,8 @@ private static string Took(long milliseconds) => milliseconds < 1000 PlanProblemKind.DependentsInTheWay or PlanProblemKind.NeighbourNeeded => StillRunning(problem), - _ => throw new ArgumentOutOfRangeException( - nameof(problem), problem.Kind, EquivalentCommand.Unhandled) + // What an ending sets off, and the refusal for a kind with no sentence, since 2026-09-30. + _ => Aftermath(problem) }; /// diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index 3b39a87..6e97561 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -89,6 +89,12 @@ "cli.plan.warning.startsAtNextBoot": "{0} is stopped, and a start type does not start it. It starts at the next restart of the machine.", "cli.plan.warning.disabledCannotStart": "{0} is disabled, and Windows refuses to start a disabled entry. To make it startable first: {1}", "cli.plan.warning.pausedCannotStart": "{0} is paused, and a start does not resume a paused service - Windows will refuse it. To resume it instead: sc.exe continue {0}", + "cli.plan.warning.recoveryRestarts.one": "Once the process behind {0} is ended, Windows starts {2} again by itself - its recovery actions say so. The stop may not last. See them with sc.exe qfailure {2}", + "cli.plan.warning.recoveryRestarts.many": "Once the process behind {0} is ended, Windows starts {1} entries again by itself - their recovery actions say so: {2}. The stop may not last. See them with sc.exe qfailure", + "cli.plan.warning.recoveryRunsProgram.one": "Once the process behind {0} is ended, Windows runs the program named in the recovery actions of {2}. See it with sc.exe qfailure {2}", + "cli.plan.warning.recoveryRunsProgram.many": "Once the process behind {0} is ended, Windows runs the programs named in the recovery actions of {1} entries: {2}. See them with sc.exe qfailure", + "cli.plan.warning.recoveryUnnamed.one": "{2} has a recovery action of a kind this tool cannot name, and Windows carries it out once the process behind {0} is ended. See it with sc.exe qfailure {2}", + "cli.plan.warning.recoveryUnnamed.many": "{1} entries have a recovery action of a kind this tool cannot name, and Windows carries it out once the process behind {0} is ended: {2}. See them with sc.exe qfailure", "cli.run.progress": "[{0}/{1}] {2} {3} ...", "cli.run.interrupted": "Interrupted. Finishing the step in flight, then putting back what was taken down. Another Ctrl+C leaves it as it is.", @@ -102,6 +108,7 @@ "cli.run.outcome.succeeded": "done in {0}", "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.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", @@ -128,6 +135,12 @@ "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.plan.problem.processIsCritical": "Windows marks the process {0} runs in as critical. Ending it stops the whole machine with a blue screen, so there is no plan.", + "cli.plan.problem.recoveryRestartsComputer.one": "{1} has restart the computer among its recovery actions, and ending the process behind {0} is exactly the failure that sets them off. There is no plan. See them with sc.exe qfailure {1}", + "cli.plan.problem.recoveryRestartsComputer.many": "These have restart the computer among their recovery actions, and ending the process behind {0} is exactly the failure that sets them off: {1}. There is no plan. See them with sc.exe qfailure", + "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.column.name": "NAME", "cli.column.displayName": "DISPLAY NAME", diff --git a/src/Bws.Core/EndingFacts.cs b/src/Bws.Core/EndingFacts.cs index ef86f96..a1494bc 100644 --- a/src/Bws.Core/EndingFacts.cs +++ b/src/Bws.Core/EndingFacts.cs @@ -54,22 +54,67 @@ namespace Bws.Core; /// person can check against Task Manager. A file time in a preview would be noise carrying no /// decision. /// -public readonly record struct EndingFacts(Reading CanBeEnded, Reading Created) +/// +/// Whether Windows marked this process critical - ending one stops the whole machine with the stop +/// error CRITICAL_PROCESS_DIED, which is a different sentence from "a service the machine +/// needs" and a much shorter one to act on. +/// +/// Here since 2026-09-30, and the plan refuses on it (stability report W-3, the owner's decision +/// of that day). Counted on the owner's machine that day: four of 114 processes behind services are +/// critical, and those four hold every one of the seven services whose recovery restarts the computer. +/// An unreadable answer is a refusal too - the one step nobody can undo is not previewed half blind. +/// +public readonly record struct EndingFacts(Reading CanBeEnded, Reading Created, Reading Critical) { /// /// The honest answer when there was nobody to ask. /// /// A named value rather than a null, because "not asked" is a state this project has a word /// for and null is not that word. A plan built without a reader behaves exactly as it did - /// before either of these facts existed - it names the process, tries, and finds out. What it + /// before any of these facts existed - it names the process, tries, and finds out. What it /// does NOT do is claim to have checked. /// public static EndingFacts NobodyAsked() => - new(Reading.NotRead(), Reading.NotRead()); + new(Reading.NotRead(), Reading.NotRead(), Reading.NotRead()); } /// -/// Asks a process the two questions above. +/// One thing the manager does when an entry's process dies without the entry saying it stopped. +/// +/// Read for the plan that ends a process and for nothing else, since 2026-09-30 (stability +/// report W-3). Ending a process IS that death - measured on the throwaway machine that day, the +/// restart came in ten endings of ten with the flag that widens these actions switched off. The +/// full recovery list with its delays belongs to phase 2 of the plan, in the listing and the details, +/// and none of this is in the machine readable output. +/// +/// Which item of the list runs is not knowable from outside. The manager counts failures since +/// the machine started and runs item N for failure N, repeating the last - and no call hands out the +/// count. So the plan asks what is ANYWHERE in the list. +/// +public enum RecoveryAction +{ + /// An item that does nothing - Microsoft's own "take no action". + Nothing, + + /// The manager starts the service again - the commonest: 204 of 312 services on one machine. + RestartService, + + /// The manager runs the command the entry names. + RunProgram, + + /// The manager restarts the computer. + RestartComputer, + + /// + /// A type Microsoft does not document. Measured 2026-09-30: Schedule carries type 4 first, + /// and sc.exe prints nothing for it. Named rather than guessed at, and never left out. + /// + Unnamed +} + +/// +/// Asks what ending a process would set off: the three questions above of the process, and what the +/// manager does afterwards to each entry living in it. /// /// Its own interface rather than a method on , and the reason is the /// promise section F of the specification makes. Read-only mode is the absence of that @@ -77,9 +122,12 @@ public static EndingFacts NobodyAsked() => /// read-only tool could not even show a preview of a forced stop, and previews are exactly what a /// read-only tool should be able to show. /// -/// It is also not part of , which is about the service control -/// manager. These questions are asked of a PROCESS, and the manager has no opinion about them - -/// the same seam already draws for the same reason. +/// It is not part of either, and since 2026-09-30 that needs a better +/// reason than "these are asked of a process", because is asked of the +/// manager. The reason is the subject: this interface answers "what happens if this process ends", +/// only a plan that ends one ever asks it, and both interfaces that build such a plan already hold +/// one. The catalog is the seam of the listing, with eight implementations, and a recovery reading +/// there would be the start of the phase 2 details rather than one plan's safety question. /// public interface IEndingFactsReader { @@ -93,4 +141,31 @@ public interface IEndingFactsReader /// is a measurement rather than a setting. /// EndingFacts Read(int processId); + + /// + /// What the manager does to this entry when its process dies, item by item. + /// + /// Absent when the entry is not there any more - it went between the listing and this + /// question, so it will not die with anything. Denied when the manager refused, with its number, + /// and the plan refuses on that: a casualty list whose consequences are known to be missing is + /// the same shape as one known to be short. + /// + Reading> ReadRecovery(string serviceName); +} + +/// +/// The reader a plan gets when there is nobody to ask - every answer is , +/// so the plan is built exactly as it was before any of these questions existed. +/// +/// An object rather than a null threaded through, for the reason +/// gives: "not asked" is a state with a name here. +/// +internal sealed class NobodyToAsk : IEndingFactsReader +{ + internal static readonly NobodyToAsk Instance = new(); + + public EndingFacts Read(int processId) => EndingFacts.NobodyAsked(); + + public Reading> ReadRecovery(string serviceName) => + Reading>.NotRead(); } diff --git a/src/Bws.Core/IScmControl.cs b/src/Bws.Core/IScmControl.cs index 362f8b1..1c74b85 100644 --- a/src/Bws.Core/IScmControl.cs +++ b/src/Bws.Core/IScmControl.cs @@ -164,4 +164,22 @@ public interface IScmControl /// Where the entry is now. The answer says whether it could be read at all. ControlAnswer Read(string serviceName); + + /// + /// What every entry is doing and which process holds it, from one enumeration - asked by the step + /// that ends a process, immediately before it does. + /// + /// The same question answers, on this side of the seam since + /// 2026-09-30 (stability report W-6, package B2). The runner holds nothing else, and the check it + /// feeds - who lives in the process NOW against who the plan said would die - is worked out above + /// this line where a test can drive it. A reading rather than an exception on failure, because + /// here a failure is not a broken listing: it is a step that refuses and ends nothing. + /// + Reading> ReadStatuses(); + + /// + /// The entries that depend on this one, whatever they are doing, asked of the manager - the same + /// question and the same answer as , for the same step. + /// + Reading> ReadDependents(string serviceName); } diff --git a/src/Bws.Core/NativeMethods.txt b/src/Bws.Core/NativeMethods.txt index 5e99763..bce1023 100644 --- a/src/Bws.Core/NativeMethods.txt +++ b/src/Bws.Core/NativeMethods.txt @@ -125,6 +125,13 @@ PROCESS_ACCESS_RIGHTS // beside the call should know it is not missing. GetProcessTimes +// Whether ending this process stops the whole machine, asked on the same narrow right as the time +// above, since 2026-09-30 (stability report W-3, package B2). Windows answers a critical process that +// dies with the stop error CRITICAL_PROCESS_DIED, so a plan that would end one is refused before it +// exists. Counted that day over the 114 processes behind services on the owner's machine: four are +// critical, and the question was refused zero times. +IsProcessCritical + // Who signed the file an entry runs, and whether the system trusts that signature. // // Read-only, and the whole block is about files rather than services - none of it can @@ -208,6 +215,16 @@ SERVICE_DELAYED_AUTO_START_INFO // "@%SystemRoot%\system32\adpsvc.dll,-103" and call it a description. The ten it does not // resolve are unreadable rather than empty, which is a different answer and stays one. SERVICE_DESCRIPTIONW + +// What the manager does when an entry's process dies without saying it stopped - restart it, run a +// program, restart the computer - at yet another level of the same call. Read for the plan that ends +// a process and for nothing else since 2026-09-30 (stability report W-3, package B2): ending the +// process is exactly the death that sets these off, measured on the throwaway machine that day. The +// flag level beside it is deliberately NOT read - it only widens the actions to polite stops that +// report an error, and it changed nothing about an ending in ten measured endings. +SERVICE_FAILURE_ACTIONSW +SC_ACTION +SC_ACTION_TYPE SERVICE_TRIGGER_INFO SERVICE_TRIGGER WIN32_ERROR diff --git a/src/Bws.Core/Planning/Aftermath.cs b/src/Bws.Core/Planning/Aftermath.cs new file mode 100644 index 0000000..015bf3c --- /dev/null +++ b/src/Bws.Core/Planning/Aftermath.cs @@ -0,0 +1,119 @@ +namespace Bws.Core.Planning; + +/// +/// What ending a process sets off after it is gone - the machine stopping, or the manager doing what each +/// dead entry's recovery list tells it to - and whether a plan may be built over that. +/// +/// Its own class since 2026-09-30 (stability report W-3, package B2), and the seam is a subject rather +/// than the size ceiling alone. works out what the ending takes DOWN. This answers +/// what comes back up, or goes further down, once nothing of the process is left - questions the first +/// half never had to ask, because until that day this tool believed a dead service stayed dead. Measured on +/// the throwaway machine the same day: the manager restarted it in ten endings of ten. +/// +/// Asked of the entry and every neighbour, never of the cascade. A cascade entry gets a polite stop, +/// and one that does not stop holds the ending back altogether (the owner's decision of 2026-09-29) - so +/// nothing in the cascade ever dies with the process, and its recovery list is never set off. +/// +internal static class Aftermath +{ + /// One entry that dies with the process, and its recovery list 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. + /// + /// Here rather than at the end of , whose file stood 14 lines of + /// code under the size ceiling's line when the question arrived on 2026-09-30. The ending is built here + /// for the same reason: it is the first thing that can only exist once this question said yes. + /// + internal static (PlanProblem? Refusal, ForcedStop.Ending? Ending) Weigh( + IEndingFactsReader processes, + EndingFacts facts, + ScmEntry target, + IReadOnlyList sharing, + ServiceAction action, + int processId) + { + var recovery = Read(processes, target, sharing); + + if (Refusal(target, recovery) is { } refused) + { + return (refused, null); + } + + return (null, new ForcedStop.Ending( + processId, + sharing, + action.Immediate, + facts.Created.IsPresent ? facts.Created.Value : null, + recovery)); + } + + /// The entry first and its neighbours in the order the listing gave them - the order every sentence names them in. + private static IReadOnlyList Read( + IEndingFactsReader processes, ScmEntry target, IReadOnlyList sharing) => + [ + .. sharing.Prepend(target) + .Select(entry => new Recovered(entry.ServiceName, processes.ReadRecovery(entry.ServiceName))) + ]; + + /// + /// Whether the process is one Windows will not survive losing - asked from the same reading as the right + /// to end it, so it is refused as early as that refusal is. + /// + /// Unreadable is a refusal too, and nothing read is nothing said. The first is the owner's rule of + /// 2026-09-30 for this step: no preview known to be missing a consequence. The second is the plan built + /// with nobody to ask, which behaves exactly as it did before this question existed. + /// + internal static PlanProblem? Critical(EndingFacts facts, ScmEntry target) => facts.Critical.Outcome switch + { + ReadOutcome.Present when facts.Critical.Value => new PlanProblem(PlanProblemKind.ProcessIsCritical, target.ServiceName, []), + ReadOutcome.Denied => new PlanProblem(PlanProblemKind.AftermathUnreadable, target.ServiceName, []), + _ => null + }; + + /// + /// 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) + { + var restarting = Having(recovery, RecoveryAction.RestartComputer); + + if (restarting.Count > 0) + { + return new PlanProblem(PlanProblemKind.RecoveryRestartsComputer, target.ServiceName, restarting); + } + + List unread = [.. recovery.Where(one => one.Actions.Outcome == ReadOutcome.Denied).Select(one => one.ServiceName)]; + + return unread.Count > 0 ? new PlanProblem(PlanProblemKind.AftermathUnreadable, target.ServiceName, unread) : null; + } + + /// + /// The three sentences a plan that is allowed can still owe somebody: who comes back, who sets a program + /// off, and who carries an item this tool has no name for. + /// + internal static void AddWarnings(List warnings, ScmEntry target, IReadOnlyList recovery) + { + Warn(warnings, target, Having(recovery, RecoveryAction.RestartService), PlanWarningKind.RecoveryRestarts); + Warn(warnings, target, Having(recovery, RecoveryAction.RunProgram), PlanWarningKind.RecoveryRunsProgram); + Warn(warnings, target, Having(recovery, RecoveryAction.Unnamed), PlanWarningKind.RecoveryUnnamed); + } + + private static void Warn(List warnings, ScmEntry target, List named, PlanWarningKind kind) + { + if (named.Count > 0) + { + warnings.Add(new PlanWarning(kind, target.ServiceName, named)); + } + } + + /// + /// Every entry whose list holds this item ANYWHERE - the manager runs item N for failure N since the + /// machine started, and no call hands out N. Absent and unread lists hold nothing. + /// + private static List Having(IReadOnlyList recovery, RecoveryAction action) => + [.. recovery.Where(one => one.Actions.IsPresent && one.Actions.Value!.Contains(action)).Select(one => one.ServiceName)]; +} diff --git a/src/Bws.Core/Planning/ForcedStop.cs b/src/Bws.Core/Planning/ForcedStop.cs index fe50233..c2587fb 100644 --- a/src/Bws.Core/Planning/ForcedStop.cs +++ b/src/Bws.Core/Planning/ForcedStop.cs @@ -29,8 +29,16 @@ internal static class ForcedStop /// halves of an identity are only worth anything together, so they travel together from the /// moment they are read. Nothing when nobody read it. /// + /// + /// What the manager does to the entry and each neighbour once the process is gone, read when the + /// plan was decided and carried to the warnings - since 2026-09-30, . + /// internal readonly record struct Ending( - int ProcessId, IReadOnlyList Sharing, bool Immediate, long? CreatedAt); + int ProcessId, + IReadOnlyList Sharing, + bool Immediate, + long? CreatedAt, + IReadOnlyList Recovery); internal static bool Asked(ActionKind kind) => kind is ActionKind.ForceStop or ActionKind.ForceRestart; @@ -50,6 +58,10 @@ internal static bool Asked(ActionKind kind) => /// Every running entry that depends on the target, whether or not the plan stops them - the plan /// stops them exactly when the ask carries . /// + /// + /// What to ask about the process and about what its ending sets off - when + /// the caller has nothing to ask, which builds the plan exactly as it was before any of it was read. + /// internal static (PlanProblem? Refusal, Ending? Ending) Decide( IReadOnlyList entries, IScmCatalog catalog, @@ -57,7 +69,7 @@ internal static (PlanProblem? Refusal, Ending? Ending) Decide( IReadOnlyList blocking, IReadOnlyList warnings, ServiceAction action, - EndingFacts facts) + IEndingFactsReader processes) { ThrowIfTheCourtesySkipsTheCascade(action); @@ -68,6 +80,11 @@ internal static (PlanProblem? Refusal, Ending? Ending) Decide( return (Because(PlanProblemKind.NoProcessToEnd, target), null); } + // ASKED ONLY ONCE THERE IS A NUMBER, and only for this ask - three handle opens against one process + // while a plan is built is nothing, and asking the system about process zero to be told there is + // none would be a call made to learn something the line above already knows. + var facts = processes.Read(processId); + // ASKED BEFORE ANYTHING ELSE IS WORKED OUT, AND THAT ORDER IS THE POINT OF RUNG FIVE. Every // question below this one is about what else comes down on the way. If the thing at the end // of that road cannot be reached at all, working out the road is time spent describing a @@ -109,11 +126,7 @@ .. ProcessNeighbours.Of(entries, target) return (needed, null); } - return (null, new Ending( - processId, - sharing, - action.Immediate, - facts.Created.IsPresent ? facts.Created.Value : null)); + return Aftermath.Weigh(processes, facts, target, sharing, action, processId); } /// @@ -129,6 +142,10 @@ .. ProcessNeighbours.Of(entries, target) /// Nothing read means nothing said. A plan built with nobody to ask behaves exactly as /// it did before this existed: it names the process, it tries, and it finds out. That is a /// worse experience and an honest one - what it never does is claim to have checked. + /// + /// A process that may be ended is still asked whether the machine survives it, since + /// 2026-09-30 - the same reading, and the same place in the order, because a critical process is + /// the other thing at the end of the road that makes working out the road pointless. /// private static PlanProblem? Unreachable(EndingFacts facts, ScmEntry target, int processId) { @@ -136,7 +153,7 @@ .. ProcessNeighbours.Of(entries, target) if (rights.Outcome == ReadOutcome.NotRead || (rights.IsPresent && rights.Value)) { - return null; + return Aftermath.Critical(facts, target); } if (rights.Outcome == ReadOutcome.Absent) @@ -393,5 +410,7 @@ internal static void AddWarnings( { warnings.Add(new PlanWarning(PlanWarningKind.CriticalService, target.ServiceName, critical)); } + + Aftermath.AddWarnings(warnings, target, ending.Recovery); } } diff --git a/src/Bws.Core/Planning/OperationPlan.cs b/src/Bws.Core/Planning/OperationPlan.cs index ead15bb..4af70cf 100644 --- a/src/Bws.Core/Planning/OperationPlan.cs +++ b/src/Bws.Core/Planning/OperationPlan.cs @@ -403,7 +403,34 @@ public enum PlanProblemKind /// --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 + NeighbourNeeded, + + /// + /// Windows marks the process critical, and ending one stops the whole machine with + /// CRITICAL_PROCESS_DIED. Refused before anything is worked out, like + /// , on the owner's decision of 2026-09-30 (stability report W-3). + /// + ProcessIsCritical, + + /// + /// An entry that dies with the process has "restart the computer" somewhere in its recovery list. + /// Related names them. + /// + /// Anywhere rather than first, on the owner's decision of 2026-09-30: the manager runs item N + /// for failure N since the machine started, and no call hands out N. On the owner's machine all seven + /// such services have it first, and all seven live in the four critical processes. + /// + RecoveryRestartsComputer, + + /// + /// What ending the process sets off could not be read in full - whether it is critical, or the recovery + /// list of an entry that dies with it. Related names the entries whose list was refused. + /// + /// The rule holds for a forced stop, the owner's decision of + /// 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 } /// 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 d1e90ad..75f8b3c 100644 --- a/src/Bws.Core/Planning/PlanBuilder.cs +++ b/src/Bws.Core/Planning/PlanBuilder.cs @@ -17,8 +17,14 @@ namespace Bws.Core.Planning; /// every caller that can reach a real machine should hand one over - both of the two in this /// product do. Without it a forced stop is planned exactly as it was before rung five of /// specification C3 existed: the process is named, the plan is built, and a refusal is -/// discovered by the step that meets it. is what that looks -/// like from the inside, and it is a value with a name rather than a null threaded through. +/// discovered by the step that meets it. is what that looks like from the +/// inside, and it is an object with a name rather than a null threaded through. +/// +/// Asked only for the ask that ends a process, and only once there is a number - the argument +/// lives at the call in since 2026-09-30, when the questions grew from +/// the process to what its ending sets off. The same handful against every running entry on every +/// listing would be a cost on the path a person waits for, paid for facts that are different a second +/// later - the argument the specification already makes about memory. /// public sealed class PlanBuilder( IReadOnlyList entries, @@ -87,7 +93,7 @@ public OperationPlan Build(ServiceAction action) if (ForcedStop.Asked(action.Kind)) { var (refusal, decided) = ForcedStop.Decide( - entries, catalog, target, blocking, warnings, action, Ask(target)); + entries, catalog, target, blocking, warnings, action, processes ?? NobodyToAsk.Instance); if (refusal is { } why) { @@ -525,26 +531,6 @@ private void AddWarnings( entries.FirstOrDefault(entry => string.Equals(entry.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase)); - /// - /// What the process behind this entry will say about itself, asked once, for the one ask that - /// ends one. - /// - /// ASKED HERE AND NOWHERE ELSE, AND ONLY FOR THAT ASK. Two handle opens against one - /// process while a plan is built is nothing. The same two against every running entry on every - /// listing would be a cost on the path a person waits for, paid for a pair of facts that are - /// different a second later - which is the argument the specification already makes about - /// memory, and the reason memory is off unless somebody asks for it. - /// - /// Not asked at all when there is no number to ask about. An entry that is not running - /// has no process, and has a word for that already - asking - /// the operating system about process zero to be told so would be a call made to learn - /// something this class already knows. - /// - private EndingFacts Ask(ScmEntry target) => - processes is not null && ProcessNeighbours.Endable(target) is { } endable - ? processes.Read(endable) - : EndingFacts.NobodyAsked(); - private static OperationPlan Refuse( ServiceAction action, PlanProblemKind kind, IReadOnlyList? related = null) => new() { diff --git a/src/Bws.Core/Planning/PlanRun.cs b/src/Bws.Core/Planning/PlanRun.cs index f79b121..d722d9e 100644 --- a/src/Bws.Core/Planning/PlanRun.cs +++ b/src/Bws.Core/Planning/PlanRun.cs @@ -124,6 +124,19 @@ public sealed record StepResult /// public bool StoppedWhileStarting { get; init; } + /// + /// The process was ended, and the entry was seen in ANOTHER process before it was seen stopped - + /// the manager started it again at once, as a recovery list or a trigger tells it to. + /// + /// A failed step with our own sentence, on the owner's decision of 2026-09-30 (stability report + /// W-3). Measured before the change: the step waited for Stopped, which with a restart at 0 ms showed + /// for 42-58 ms or not at all, and reported running out of time after the whole minute - while in the + /// same shape the manager marks the death 3-17 ms after the ending. Not in the machine readable output, + /// like the fact above - error carries the + /// sentence and processId the new process. + /// + public bool StartedAgain { 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.Ending.cs b/src/Bws.Core/Planning/PlanRunner.Ending.cs new file mode 100644 index 0000000..ade7383 --- /dev/null +++ b/src/Bws.Core/Planning/PlanRunner.Ending.cs @@ -0,0 +1,107 @@ +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. +/// +/// 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. +/// +public sealed partial class PlanRunner +{ + /// + /// A refusal when the process is no longer what the plan said would die with it - or nothing, when it is. + /// + /// THE PLAN FROZE ITS CASUALTY LIST, AND A MACHINE KEEPS MOVING BETWEEN THE PREVIEW AND THE PRESS. + /// A service can start inside a shared process after the preview, and a dependant can start outside it - + /// until this existed the first died without a word and the second lost what it depends on, because the + /// ending asks nobody. The polite stop in front of it did not help with the second either: the manager + /// refuses it with 1051 while a dependant runs, and a refused stop is exactly what the ending stands behind. + /// + /// It declines rather than works anything out afresh, the way does: the + /// plan that was shown or nothing at all. Neighbours its own earlier steps stopped hold no process any more, + /// so they are not "in" it and never look like strangers. + /// + /// One enumeration and one question per entry the process holds - 105 of 110 processes on a measured + /// machine hold one service - asked once, immediately before the one call that cannot be undone. + /// + private ControlAnswer? Crowded(PlanStep step) + { + var statuses = control.ReadStatuses(); + + if (!statuses.IsPresent) + { + return ControlAnswer.Refused(statuses.ErrorCode, Unchecked); + } + + List held = + [ + .. statuses.Value! + .Where(entry => entry.ProcessId.IsPresent && entry.ProcessId.Value == step.ProcessId) + .Select(entry => entry.ServiceName) + ]; + + var named = new HashSet(step.TakesWithIt ?? [], StringComparer.OrdinalIgnoreCase) { step.ServiceName }; + List newcomers = [.. held.Where(name => !named.Contains(name))]; + + return newcomers.Count > 0 + ? ControlAnswer.Refused(0, string.Concat(Listed(newcomers), MovedIn)) + : Needing(held, statuses.Value!); + } + + /// + /// Running entries OUTSIDE the process that depend on anything inside it. Dependants inside it die with it + /// and are the question above, and stopped ones need nothing from a process. + /// + private ControlAnswer? Needing(List held, IReadOnlyList statuses) + { + var inside = new HashSet(held, StringComparer.OrdinalIgnoreCase); + var running = new HashSet( + statuses.Where(entry => entry.Status != EntryStatus.Stopped).Select(entry => entry.ServiceName), + StringComparer.OrdinalIgnoreCase); + var needing = new List(); + + foreach (var name in held) + { + var dependents = control.ReadDependents(name); + + if (dependents.Outcome == ReadOutcome.Denied) + { + return ControlAnswer.Refused(dependents.ErrorCode, Unchecked); + } + + needing.AddRange(dependents.IsPresent + ? dependents.Value!.Where(dependent => running.Contains(dependent) && !inside.Contains(dependent)) + : []); + } + + return needing.Count == 0 + ? null + : ControlAnswer.Refused(0, string.Concat(Listed([.. needing.Distinct(StringComparer.OrdinalIgnoreCase)]), NowNeeded)); + } + + private static string Listed(List names) => string.Join(", ", names); + + /// + /// Said in our own words, like , because nothing refused it but this class. The + /// names go in front of each sentence, as they would in a person's report of it. + /// + private const string MovedIn = + " started running in this process after the plan was built, and the plan does not name them, so " + + "nothing was ended. Ask again to build a plan against the machine as it is now."; + + private const string NowNeeded = + " started running after the plan was built and depend on an entry living in this process, so nothing " + + "was ended. Stop them first, or ask again."; + + private const string Unchecked = + "Who lives in this process 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. + /// + private const string CameBack = + "The process was ended, and the service was already running again in a new process before it was " + + "seen stopped - Windows starts a service again by itself when its recovery actions say so."; +} diff --git a/src/Bws.Core/Planning/PlanRunner.Waiting.cs b/src/Bws.Core/Planning/PlanRunner.Waiting.cs index 3153172..1c951b2 100644 --- a/src/Bws.Core/Planning/PlanRunner.Waiting.cs +++ b/src/Bws.Core/Planning/PlanRunner.Waiting.cs @@ -114,7 +114,12 @@ Settled.Over when OnItsWay(target, Where(seen)) => private StepResult WaitFor( PlanStep step, EntryStatus target, TimeSpan timeout, TimeSpan started, Func halted, bool asked) { - var watched = Watch(step.ServiceName, now => now == target || FellBack(target, now), timeout, halted); + var watched = Watch( + step.ServiceName, + now => now == target || FellBack(target, now), + timeout, + halted, + ended: step.Operation == StepOperation.Terminate ? step.ProcessId : null); // The last reading that worked rather than the one that ended it, because the point of both // is the step that gives up: by then the entry is where nobody can act on it, and the last @@ -127,6 +132,12 @@ private StepResult WaitFor( Settled.Over when Where(seen) == target => Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)), + // 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. + Settled.Over when step.Operation == StepOperation.Terminate => + Refused(step, ControlAnswer.Refused(0, CameBack), Where(seen), Holding(seen), started) + with { StartedAgain = true }, + Settled.Over => StoppedAgain(step, seen, started), Settled.Halted when !asked => Skipped(step, SkipReason.Cancelled, Where(seen), Holding(seen), Elapsed(started)), @@ -150,7 +161,14 @@ Settled.Over when Where(seen) == target => /// moving. Until that day it was a wall across the whole step, and a service stopping honestly for /// seventy seconds was given up on at sixty. /// - private Watched Watch(string serviceName, Func over, TimeSpan timeout, Func halted) + /// + /// The process a step just ended, for that step and nothing else: a reading held by ANY OTHER process + /// is over as well, since 2026-09-30 (stability report W-3). Measured on the throwaway machine that + /// day: with a restart at 0 ms the entry showed Stopped for 42-58 ms or not at all, and watching only + /// for Stopped waited the whole minute over a service already running again. + /// + private Watched Watch( + string serviceName, Func over, TimeSpan timeout, Func halted, int? ended = null) { var pause = FirstLook; ControlAnswer? seen = null; @@ -174,7 +192,7 @@ private Watched Watch(string serviceName, Func over, TimeSpan seen = answer; - if (over(progress.Status)) + if (over(progress.Status) || Elsewhere(progress, ended)) { return new Watched(Settled.Over, answer, seen); } @@ -221,6 +239,15 @@ private static bool OnItsWay(EntryStatus target, EntryStatus status) => target = private static bool FellBack(EntryStatus target, EntryStatus status) => target == EntryStatus.Running && status == EntryStatus.Stopped; + /// + /// Held by a process, and not the one ended. Zero is the manager saying none - in the two traces printed + /// on 2026-09-30 a restarting entry sat in StartPending with no process for 31 and 45 ms while the + /// manager started one - so it is + /// watched on rather than read as the entry having come back. + /// + private static bool Elsewhere(ServiceProgress progress, int? ended) => + ended is { } gone && progress.ProcessId != 0 && progress.ProcessId != (uint)gone; + /// /// A start that ended in Stopped, with the service's own exit code as the number. /// diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs index 6a5e334..30722e7 100644 --- a/src/Bws.Core/Planning/PlanRunner.cs +++ b/src/Bws.Core/Planning/PlanRunner.cs @@ -320,9 +320,10 @@ private ControlAnswer End(PlanStep step, ControlAnswer before) // 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. + // 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 - ? control.Terminate(holding.Value, step.ProcessCreatedAt) + ? Crowded(step) ?? control.Terminate(holding.Value, step.ProcessCreatedAt) : ControlAnswer.Refused(0, ProcessMoved); } diff --git a/src/Bws.Core/Planning/PlanWarnings.cs b/src/Bws.Core/Planning/PlanWarnings.cs index 037123a..7b6215c 100644 --- a/src/Bws.Core/Planning/PlanWarnings.cs +++ b/src/Bws.Core/Planning/PlanWarnings.cs @@ -172,7 +172,32 @@ public enum PlanWarningKind /// nothing the reading does not already say. Resuming is a write of its own that this tool does not /// have, so the sentence names the system's way to do it. /// - PausedCannotStart + PausedCannotStart, + + /// + /// Ending this process makes the manager start these entries again - their recovery lists say + /// "restart the service" somewhere. names them, the entry first. + /// + /// The warning a forced stop needed and never had, until 2026-09-30 (stability report W-3, the + /// owner's decision of 2026-09-29). Measured on the throwaway machine before the change: with a restart + /// at 3000 ms, bws kill --force reported the step succeeded, the run complete and exit code 0 - + /// and three seconds later the service was running again. A warning rather than a refusal, because the + /// administrator may want exactly that, and on the owner's machine 204 of 312 services carry this. + /// + RecoveryRestarts, + + /// + /// Ending this process makes the manager run the program in one of these entries' recovery lists. + /// Measured 2026-09-30: the program ran after an ending. The same decision as the one above. + /// + RecoveryRunsProgram, + + /// + /// One of these entries has a recovery item of a type this tool has no name for - Microsoft documents + /// four and Schedule carries a fifth. Said rather than guessed at, and never left out: the + /// owner's decision of 2026-09-30. + /// + RecoveryUnnamed } /// diff --git a/src/Bws.Core/ScmDetailReader.Recovery.cs b/src/Bws.Core/ScmDetailReader.Recovery.cs new file mode 100644 index 0000000..2781dba --- /dev/null +++ b/src/Bws.Core/ScmDetailReader.Recovery.cs @@ -0,0 +1,109 @@ +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Services; + +namespace Bws.Core; + +/// +/// What the manager does when an entry's process dies - the one level of the configuration call read +/// for a single plan rather than for the listing. +/// +/// Its own file since 2026-09-30 (stability report W-3, package B2), because the rest of this +/// class feeds the listing and this feeds only the plan that ends a process. The size ceiling asked for +/// the seam as well - the file beside it holds 172 lines of code against a line of 202. +/// +internal static partial class ScmDetailReader +{ + /// + /// Every item of the entry's recovery list, in order, reduced to what the plan needs: which kind. + /// + /// The delays and the reset period are read and dropped on purpose. Which item runs depends on + /// a failure count no call hands out, so the plan asks what is anywhere in the list, and a delay + /// changes nothing about whether a restart comes - only when. Phase 2 reads the rest for a person. + /// + /// An entry with no recovery at all answers a structure with no items - measured over 312 + /// services on 2026-09-30, the sizing call never came back empty - so this is an empty list rather + /// than an absence. Absent stays for an answer with nothing in it at all. + /// + internal static unsafe Reading> ReadRecovery(SafeHandle service) + { + if (!Sized(service, SERVICE_CONFIG.SERVICE_CONFIG_FAILURE_ACTIONS, out var needed, out var refusal)) + { + return refusal == 0 + ? Reading>.Absent() + : Refused>(refusal); + } + + var buffer = new byte[needed]; + + // PINNED FROM THE CALL TO THE WALK, for the reason ReadTriggers gives: the structure carries an + // absolute pointer into this very block. Backlog 297. + fixed (byte* pinned = buffer) + { + if (!PInvoke.QueryServiceConfig2W( + service, SERVICE_CONFIG.SERVICE_CONFIG_FAILURE_ACTIONS, + new Span(pinned, buffer.Length), out _)) + { + return Refused>(Marshal.GetLastWin32Error()); + } + + return Items(pinned, buffer.Length); + } + } + + /// + /// The items, walked inside the block that holds them. + /// + /// BOUNDED BY THE BLOCK, and an answer pointing outside it is a refusal rather than an empty list. + /// `docs/09` names the unchecked count as the trust this project places in the operating system + /// everywhere else. Here the answer decides whether a plan that can restart a computer is refused, so + /// a malformed one becomes "could not read" - which refuses - and never "nothing configured", which + /// would let the plan through saying nothing. + /// + private static unsafe Reading> Items(byte* block, int length) + { + if (length < sizeof(SERVICE_FAILURE_ACTIONSW)) + { + return Reading>.Absent(); + } + + var header = (SERVICE_FAILURE_ACTIONSW*)block; + var count = header->cActions; + + if (count == 0) + { + return Reading>.Present([]); + } + + var offset = (byte*)header->lpsaActions - block; + + if (offset < 0 || offset + ((long)count * sizeof(SC_ACTION)) > length) + { + return Refused>((int)WIN32_ERROR.ERROR_INVALID_DATA); + } + + var items = new RecoveryAction[count]; + + for (var index = 0; index < count; index++) + { + items[index] = Kind(header->lpsaActions[index].Type); + } + + return Reading>.Present(items); + } + + /// + /// The four documented kinds by name, and everything else as one that is named as unknown - never as + /// the likeliest of the four. Schedule carries type 4 on this project's machine, which Microsoft does + /// not document and sc.exe does not print. + /// + private static RecoveryAction Kind(SC_ACTION_TYPE type) => type switch + { + SC_ACTION_TYPE.SC_ACTION_NONE => RecoveryAction.Nothing, + SC_ACTION_TYPE.SC_ACTION_RESTART => RecoveryAction.RestartService, + SC_ACTION_TYPE.SC_ACTION_RUN_COMMAND => RecoveryAction.RunProgram, + SC_ACTION_TYPE.SC_ACTION_REBOOT => RecoveryAction.RestartComputer, + _ => RecoveryAction.Unnamed + }; +} diff --git a/src/Bws.Core/ScmDetailReader.cs b/src/Bws.Core/ScmDetailReader.cs index f4f7fbd..6ebd8df 100644 --- a/src/Bws.Core/ScmDetailReader.cs +++ b/src/Bws.Core/ScmDetailReader.cs @@ -30,7 +30,7 @@ namespace Bws.Core; /// walks off the end of an array. The walks moved to , which is also /// where they can be handed bytes by a test rather than needing a machine. /// -internal static class ScmDetailReader +internal static partial class ScmDetailReader { /// /// Whether an entry is marked to start late. diff --git a/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs b/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs new file mode 100644 index 0000000..f5a0d9e --- /dev/null +++ b/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs @@ -0,0 +1,44 @@ +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.Foundation; + +namespace Bws.Core; + +/// +/// The one question in this reader asked of the MANAGER rather than of a process: what it does to an +/// entry when the entry's process dies. Since 2026-09-30, stability report W-3. +/// +public sealed partial class WindowsEndingFactsReader +{ + /// + /// Opens the entry for configuration and nothing else - the same right the listing already holds on + /// every entry, so this can be refused only where the listing's start type is refused too. + /// + public Reading> ReadRecovery(string serviceName) + { + using var manager = PInvoke.OpenSCManager( + lpMachineName: null!, + lpDatabaseName: null!, + dwDesiredAccess: PInvoke.SC_MANAGER_CONNECT); + + if (manager.IsInvalid) + { + return Unanswered(Marshal.GetLastWin32Error()); + } + + using var service = PInvoke.OpenService(manager, serviceName, PInvoke.SERVICE_QUERY_CONFIG); + + return service.IsInvalid + ? Unanswered(Marshal.GetLastWin32Error()) + : ScmDetailReader.ReadRecovery(service); + } + + /// + /// An entry that went between the listing and this question is not a refusal - it will not die with + /// anything, so it has no consequences to read. Every other number is the manager saying no. + /// + private static Reading> Unanswered(int code) => + code == (int)WIN32_ERROR.ERROR_SERVICE_DOES_NOT_EXIST + ? Reading>.Absent() + : Reading>.Denied(code, ManagerTerms.Describe(code)); +} diff --git a/src/Bws.Core/WindowsEndingFactsReader.cs b/src/Bws.Core/WindowsEndingFactsReader.cs index 4268319..44a496c 100644 --- a/src/Bws.Core/WindowsEndingFactsReader.cs +++ b/src/Bws.Core/WindowsEndingFactsReader.cs @@ -7,9 +7,10 @@ namespace Bws.Core; /// -/// Asks a real process the two questions in . +/// Asks a real process the three questions in , and the manager the one in +/// (the other half of this class, since 2026-09-30). /// -/// TWO HANDLES FOR TWO QUESTIONS, AND COMBINING THEM WOULD MAKE ONE OF THE ANSWERS A GUESS. +/// A HANDLE PER QUESTION, AND COMBINING THE FIRST TWO WOULD MAKE ONE OF THE ANSWERS A GUESS. /// A handle opened for several rights at once is refused when ANY of them is refused, so a single /// open asking for both would come back "no" without saying which right was missing - and the /// first question is precisely "is the right to end it there". Two opens cost two calls on one @@ -31,10 +32,10 @@ namespace Bws.Core; /// on somebody's production server, and what makes a preview able to say "this cannot be done" /// without having tried. /// -public sealed class WindowsEndingFactsReader : IEndingFactsReader +public sealed partial class WindowsEndingFactsReader : IEndingFactsReader { public EndingFacts Read(int processId) => - new(WhetherItCanBeEnded(processId), WhenItStarted(processId)); + new(WhetherItCanBeEnded(processId), WhenItStarted(processId), WhetherItIsCritical(processId)); /// /// Opens a handle carrying the right to end this process, and closes it. @@ -82,6 +83,31 @@ private static Reading WhenItStarted(int processId) return Reading.Present(FileTimeOf(created)); } + /// + /// Whether Windows stops the whole machine when this process dies. + /// + /// A third handle on the same narrow right as the creation time rather than a second question on + /// that one, so that each answer keeps its own refusal and the reading above stays exactly the + /// code it was. One more open while a plan is built is nothing - counted on 2026-09-30 over the 114 + /// processes behind services on the owner's machine, this question was refused zero times. + /// + private static Reading WhetherItIsCritical(int processId) + { + using var process = PInvoke.OpenProcess_SafeHandle( + PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_LIMITED_INFORMATION, + bInheritHandle: false, + (uint)processId); + + if (process.IsInvalid) + { + return Refusal(Marshal.GetLastWin32Error()); + } + + return PInvoke.IsProcessCritical(process, out var critical) + ? Reading.Present(critical) + : Refusal(Marshal.GetLastWin32Error()); + } + /// /// Two quite different things arrive as a failed open, and the number is what tells them apart. /// diff --git a/src/Bws.Core/WindowsScmControl.Company.cs b/src/Bws.Core/WindowsScmControl.Company.cs new file mode 100644 index 0000000..efb4893 --- /dev/null +++ b/src/Bws.Core/WindowsScmControl.Company.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; + +namespace Bws.Core; + +/// +/// The two readings the step that ends a process takes just before it does: who lives in the process, +/// and who depends on them. Since 2026-09-30, stability report W-6, package B2. +/// +/// Handed to the catalog rather than written again, because these are the listing's own questions +/// and a second implementation would be a second answer to one question. The catalog is built per call: +/// it holds nothing but a setting about network paths that neither of these reads, so there is no state +/// to share and nothing to keep alive between two plans. +/// +/// In a file of its own because the size ceiling asked for it - the file beside it holds 198 lines +/// of code against a line of 202. +/// +public sealed partial class WindowsScmControl +{ + public Reading> ReadStatuses() + { + try + { + return Reading>.Present(new WindowsScmCatalog().ReadStatuses()); + } + catch (Win32Exception refused) + { + // The catalog throws, because a listing that failed is a broken screen. Here it is a step that + // refuses and ends nothing, so it goes back as the refusal it is, number and words. + return Reading>.Denied(refused.NativeErrorCode, refused.Message); + } + } + + public Reading> ReadDependents(string serviceName) => + new WindowsScmCatalog().ReadDependents(serviceName); +} diff --git a/src/Bws.Core/WindowsScmControl.cs b/src/Bws.Core/WindowsScmControl.cs index de2e1bf..973a819 100644 --- a/src/Bws.Core/WindowsScmControl.cs +++ b/src/Bws.Core/WindowsScmControl.cs @@ -18,7 +18,7 @@ namespace Bws.Core; /// the way its administrator set it up, rather than failing on a right we took for /// convenience. /// -public sealed class WindowsScmControl : IScmControl +public sealed partial class WindowsScmControl : IScmControl { public ControlAnswer Request(string serviceName, StepOperation operation) => operation switch { diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index 174d03b..703e0c4 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -276,6 +276,12 @@ "gui.plan.warning.startsAtNextBoot": "{0} is stopped, and a startup type does not start it. It starts at the next restart of the machine.", "gui.plan.warning.disabledCannotStart": "{0} is disabled, and Windows refuses to start a disabled entry. Change its startup type first.", "gui.plan.warning.pausedCannotStart": "{0} is paused, and a start does not resume a paused service - Windows will refuse it.", + "gui.plan.warning.recoveryRestarts.one": "Once the process behind {0} is ended, Windows starts {2} again by itself - its recovery actions say so. The stop may not last.", + "gui.plan.warning.recoveryRestarts.many": "Once the process behind {0} is ended, Windows starts {1} entries again by itself - their recovery actions say so: {2}. The stop may not last.", + "gui.plan.warning.recoveryRunsProgram.one": "Once the process behind {0} is ended, Windows runs the program named in the recovery actions of {2}.", + "gui.plan.warning.recoveryRunsProgram.many": "Once the process behind {0} is ended, Windows runs the programs named in the recovery actions of {1} entries: {2}.", + "gui.plan.warning.recoveryUnnamed.one": "{2} has a recovery action of a kind this tool cannot name, and Windows carries it out once the process behind {0} is ended.", + "gui.plan.warning.recoveryUnnamed.many": "{1} entries have a recovery action of a kind this tool cannot name, and Windows carries it out once the process behind {0} is ended: {2}.", "gui.plan.problems": "Not included, and why", "gui.plan.problem.unknownService.one": "There is no service called {0} any more.", @@ -294,6 +300,12 @@ "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.processIsCritical": "Windows marks the process {0} runs in as critical. Ending it stops the whole machine with a blue screen.", + "gui.plan.problem.recoveryRestartsComputer.one": "{1} has restart the computer among its recovery actions, and ending the process behind {0} is exactly the failure that sets them off.", + "gui.plan.problem.recoveryRestartsComputer.many": "These have restart the computer among their recovery actions, and ending the process behind {0} is exactly the failure that sets them off: {1}.", + "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.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", @@ -340,6 +352,7 @@ "gui.plan.failures": "What did not work", "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.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 new file mode 100644 index 0000000..5c48a9e --- /dev/null +++ b/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs @@ -0,0 +1,57 @@ +using Bws.Core.Planning; + +namespace Bws.Gui.ViewModels; + +/// +/// What a plan that ends a process says about what comes after it - three warnings and three refusals, +/// since 2026-09-30 (stability report W-3, package B2). +/// +/// Its own file, reached from the discard arm of the two switches beside it, because the warning +/// switch stands one fork under the complexity ceiling and three more arms would have taken it two over. +/// The terminal's PlanText is cut the same way, so the two keep one shape. Each key is written out in full +/// inside the call, for the reason the head of the warning switch gives: a key built as an expression is +/// one TextKeyGuards cannot find. +/// +internal static partial class PlanWords +{ + private static string Aftermath(PlanWarning warning) => warning.Kind switch + { + PlanWarningKind.RecoveryRestarts => warning.Related.Count == 1 + ? Texts.Of("gui.plan.warning.recoveryRestarts.one", warning.ServiceName, warning.Related.Count, Listed(warning.Related)) + : Texts.Of("gui.plan.warning.recoveryRestarts.many", warning.ServiceName, warning.Related.Count, Listed(warning.Related)), + + PlanWarningKind.RecoveryRunsProgram => warning.Related.Count == 1 + ? Texts.Of("gui.plan.warning.recoveryRunsProgram.one", warning.ServiceName, warning.Related.Count, Listed(warning.Related)) + : Texts.Of("gui.plan.warning.recoveryRunsProgram.many", warning.ServiceName, warning.Related.Count, Listed(warning.Related)), + + PlanWarningKind.RecoveryUnnamed => warning.Related.Count == 1 + ? Texts.Of("gui.plan.warning.recoveryUnnamed.one", warning.ServiceName, warning.Related.Count, Listed(warning.Related)) + : Texts.Of("gui.plan.warning.recoveryUnnamed.many", warning.ServiceName, warning.Related.Count, Listed(warning.Related)), + + _ => throw new ArgumentOutOfRangeException( + nameof(warning), warning.Kind, EquivalentCommand.Unhandled) + }; + + /// + /// 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. + /// + private static string Aftermath(PlanProblem problem) => problem.Kind switch + { + PlanProblemKind.ProcessIsCritical => Texts.Of("gui.plan.problem.processIsCritical", problem.ServiceName), + + PlanProblemKind.RecoveryRestartsComputer => problem.Related.Count == 1 + ? Texts.Of("gui.plan.problem.recoveryRestartsComputer.one", problem.ServiceName, Listed(problem.Related)) + : Texts.Of("gui.plan.problem.recoveryRestartsComputer.many", problem.ServiceName, Listed(problem.Related)), + + PlanProblemKind.AftermathUnreadable when problem.Related.Count == 0 => + Texts.Of("gui.plan.problem.aftermathUnreadable.process", problem.ServiceName), + + PlanProblemKind.AftermathUnreadable => problem.Related.Count == 1 + ? Texts.Of("gui.plan.problem.aftermathUnreadable.one", problem.ServiceName, Listed(problem.Related)) + : Texts.Of("gui.plan.problem.aftermathUnreadable.many", problem.ServiceName, Listed(problem.Related)), + + _ => throw new ArgumentOutOfRangeException( + nameof(problem), problem.Kind, EquivalentCommand.Unhandled) + }; +} diff --git a/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs b/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs index fb9b2f6..2cdd23a 100644 --- a/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs +++ b/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs @@ -105,8 +105,8 @@ internal static partial class PlanWords // kind but one used to fall through to "is already in that state, so nothing would change" - // a warning added without a sentence would have said something confident and wrong about a // machine rather than nothing at all. The terminal's own switch had the same shape and was - // changed the same day. - _ => throw new ArgumentOutOfRangeException( - nameof(warning), warning.Kind, EquivalentCommand.Unhandled) + // changed the same day. Since 2026-09-30 the refusal stands at the end of the next switch + // along, which names what an ending sets off - this one is one fork under the ceiling. + _ => Aftermath(warning) }; } diff --git a/src/Bws.Gui/ViewModels/PlanWords.cs b/src/Bws.Gui/ViewModels/PlanWords.cs index 4b4e70d..9f65af8 100644 --- a/src/Bws.Gui/ViewModels/PlanWords.cs +++ b/src/Bws.Gui/ViewModels/PlanWords.cs @@ -184,8 +184,8 @@ internal static IReadOnlyList Describe(IEnumerable warnings ? 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) + // What an ending sets off, and the refusal for a kind with no sentence, since 2026-09-30. + _ => Aftermath(problem) }; internal static string Listed(IReadOnlyList names) => string.Join(", ", names); @@ -307,6 +307,11 @@ internal static string Describe(StepResult result) => result.ErrorCode, result.Error ?? string.Empty), + // NOT "WOULD NOT END", because the process did end (W-3, 2026-09-30) - the manager started the + // service again before anybody saw it stopped, and the new process is the thing to look at. + { StartedAgain: true } => Texts.Of( + "gui.plan.failure.startedAgain", result.Step.ServiceName, result.ProcessId.Value), + _ => Texts.Of( "gui.plan.failure.refused", result.Step.ServiceName, diff --git a/src/Bws.Gui/ViewModels/Planned.Forcing.cs b/src/Bws.Gui/ViewModels/Planned.Forcing.cs index 190d19c..0154cee 100644 --- a/src/Bws.Gui/ViewModels/Planned.Forcing.cs +++ b/src/Bws.Gui/ViewModels/Planned.Forcing.cs @@ -176,6 +176,12 @@ public string Typed /// 2026-09-16, so the box and the sentence over it can never disagree about which warnings /// they are about. Still a decision each time a kind is added, exactly as the paragraph above /// says. + /// + /// Decided on 2026-09-30 for the three recovery warnings of a forced stop: NOT heavy, the + /// owner's decision. A restart makes the effect smaller rather than larger - the service comes + /// back - and on the owner's machine 79 of 114 processes would carry it, so a typed name there + /// would become the ritual `docs/11` 9.2 warns about. The two recovery outcomes that do reach + /// past the entry, a computer restart and a critical process, are refusals and never get here. /// private static bool Heavy(PlanWarning warning) => warning.Kind is PlanWarningKind.TerminationTakesWithIt diff --git a/tests/Bws.Cli.Tests/AftermathSentenceTests.cs b/tests/Bws.Cli.Tests/AftermathSentenceTests.cs new file mode 100644 index 0000000..21d920b --- /dev/null +++ b/tests/Bws.Cli.Tests/AftermathSentenceTests.cs @@ -0,0 +1,77 @@ +using Bws.Core; +using Bws.Core.Planning; + +namespace Bws.Cli.Tests; + +/// +/// The terminal's lines for what ending a process sets off - stability report W-3, package B2, 2026-09-30. +/// +/// Each of these was a kind with no sentence at all until that day, and both switches it arrives +/// through end in a refusal rather than a borrowed sentence - so a missing arm here is an exception in +/// front of somebody about to end a process. These pin which key each shape reaches. +/// +public sealed class AftermathSentenceTests +{ + [Theory] + [InlineData(PlanWarningKind.RecoveryRestarts, "cli.plan.warning.recoveryRestarts")] + [InlineData(PlanWarningKind.RecoveryRunsProgram, "cli.plan.warning.recoveryRunsProgram")] + [InlineData(PlanWarningKind.RecoveryUnnamed, "cli.plan.warning.recoveryUnnamed")] + public void Each_warning_has_its_own_sentence_in_both_numbers(PlanWarningKind kind, string key) + { + Assert.Equal( + Texts.Of($"{key}.one", "Spooler", 1, "Spooler"), + PlanText.Describe(new PlanWarning(kind, "Spooler", ["Spooler"]))); + + Assert.Equal( + Texts.Of($"{key}.many", "Spooler", 2, "Spooler, Fax"), + PlanText.Describe(new PlanWarning(kind, "Spooler", ["Spooler", "Fax"]))); + } + + [Fact] + public void A_critical_process_says_the_machine_stops() + { + Assert.Equal( + Texts.Of("cli.plan.problem.processIsCritical", "RpcSs"), + PlanText.Describe(new PlanProblem(PlanProblemKind.ProcessIsCritical, "RpcSs", []))); + } + + [Fact] + public void An_unreadable_consequence_with_no_names_is_about_the_process_itself() + { + Assert.Equal( + Texts.Of("cli.plan.problem.aftermathUnreadable.process", "Spooler", string.Empty), + PlanText.Describe(new PlanProblem(PlanProblemKind.AftermathUnreadable, "Spooler", []))); + + Assert.Equal( + Texts.Of("cli.plan.problem.aftermathUnreadable.one", "Spooler", "Fax"), + PlanText.Describe(new PlanProblem(PlanProblemKind.AftermathUnreadable, "Spooler", ["Fax"]))); + } + + [Fact] + public void A_computer_restart_names_who_carries_it() + { + Assert.Equal( + Texts.Of("cli.plan.problem.recoveryRestartsComputer.many", "DcomLaunch", "Power, SystemEventsBroker"), + PlanText.Describe(new PlanProblem( + PlanProblemKind.RecoveryRestartsComputer, "DcomLaunch", ["Power", "SystemEventsBroker"]))); + } + + [Fact] + public void An_ending_the_manager_undid_at_once_is_not_called_refused() + { + var said = PlanText.Describe(new StepResult + { + Step = new PlanStep("Spooler", "Print Spooler", StepOperation.Terminate, StepReason.Requested, ProcessId: 4812), + Outcome = StepOutcome.Failed, + SkippedBecause = null, + Status = EntryStatus.Running, + ProcessId = Reading.Present(5555), + ErrorCode = 0, + Error = "ours", + Milliseconds = 16, + StartedAgain = true + }); + + Assert.Equal(Texts.Of("cli.run.outcome.startedAgain", 5555, "16 ms"), said); + } +} diff --git a/tests/Bws.Core.Tests/AftermathTests.cs b/tests/Bws.Core.Tests/AftermathTests.cs new file mode 100644 index 0000000..5671256 --- /dev/null +++ b/tests/Bws.Core.Tests/AftermathTests.cs @@ -0,0 +1,137 @@ +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 says about what comes after it - stability report W-3, the owner's +/// decisions of 2026-09-29 and 2026-09-30. +/// +/// Measured before a line changed (the throwaway machine, 2026-09-30): the manager restarted a +/// service in ten endings of ten when its recovery list said so, and a program in the list ran - while the +/// preview of bws kill said nothing about either. Every test here is a preview that used to be +/// silent about something the ending sets off. +/// +/// The machine is the measured chain with Netlogon at the end of it, so nothing depends on the +/// entry being ended - alone in process 7777, or sharing it with SessionEnv. +/// +public sealed class AftermathTests +{ + private const int Netlogon = 7777; + + [Fact] + public void A_restart_in_the_recovery_list_is_said_before_anything_is_ended() + { + var facts = new FakeEndingFacts().Recovering("Netlogon", RecoveryAction.RestartService, RecoveryAction.Nothing); + + var plan = Forced(Separate(), facts); + + Assert.Empty(plan.Problems); + Assert.Equal("Netlogon", Assert.Single(Warning(plan, PlanWarningKind.RecoveryRestarts).Related)); + } + + [Fact] + public void A_neighbour_that_sets_a_program_off_is_named_and_the_entry_is_asked_first() + { + var facts = new FakeEndingFacts().Recovering("SessionEnv", RecoveryAction.Nothing, RecoveryAction.RunProgram); + + var plan = Forced(Sharing(), facts); + + Assert.Equal("SessionEnv", Assert.Single(Warning(plan, PlanWarningKind.RecoveryRunsProgram).Related)); + Assert.Equal(["Netlogon", "SessionEnv"], facts.AskedRecovery); + } + + [Fact] + public void An_item_this_tool_has_no_name_for_is_said_rather_than_guessed_at() + { + var facts = new FakeEndingFacts().Recovering("Netlogon", RecoveryAction.Unnamed, RecoveryAction.RestartService); + + var plan = Forced(Separate(), facts); + + Assert.Equal("Netlogon", Assert.Single(Warning(plan, PlanWarningKind.RecoveryUnnamed).Related)); + Assert.Equal("Netlogon", Assert.Single(Warning(plan, PlanWarningKind.RecoveryRestarts).Related)); + } + + [Fact] + public void A_computer_restart_anywhere_in_a_neighbours_list_is_a_refusal() + { + // Third, not first: which item runs depends on a failure count nobody can read. + var facts = new FakeEndingFacts().Recovering( + "SessionEnv", RecoveryAction.RestartService, RecoveryAction.RestartService, RecoveryAction.RestartComputer); + + var plan = Forced(Sharing(), facts); + + var problem = Assert.Single(plan.Problems); + Assert.Equal(PlanProblemKind.RecoveryRestartsComputer, problem.Kind); + Assert.Equal("SessionEnv", Assert.Single(problem.Related)); + Assert.Empty(plan.Steps); + } + + [Fact] + public void A_recovery_list_the_manager_will_not_show_is_a_refusal() + { + var plan = Forced(Separate(), new FakeEndingFacts().RefusingRecovery("Netlogon")); + + var problem = Assert.Single(plan.Problems); + Assert.Equal(PlanProblemKind.AftermathUnreadable, problem.Kind); + Assert.Equal("Netlogon", Assert.Single(problem.Related)); + } + + [Fact] + public void A_critical_process_is_refused_before_anything_else_is_asked() + { + var facts = new FakeEndingFacts().Critical(Netlogon); + + var plan = Forced(Sharing(), facts); + + Assert.Equal(PlanProblemKind.ProcessIsCritical, Assert.Single(plan.Problems).Kind); + + // Asked from the same reading as the right to end it, so nothing further was worked out. + Assert.Empty(facts.AskedRecovery); + } + + [Fact] + public void Not_knowing_whether_the_process_is_critical_is_a_refusal() + { + var plan = Forced(Separate(), new FakeEndingFacts().CriticalUnreadable(Netlogon)); + + var problem = Assert.Single(plan.Problems); + Assert.Equal(PlanProblemKind.AftermathUnreadable, problem.Kind); + Assert.Empty(problem.Related); + } + + [Fact] + public void An_entry_gone_from_the_manager_since_the_listing_sets_nothing_off() + { + var plan = Forced(Separate(), new FakeEndingFacts().GoneFromTheManager("Netlogon")); + + Assert.Empty(plan.Problems); + Assert.DoesNotContain(plan.Warnings, warning => warning.Kind is PlanWarningKind.RecoveryRestarts + or PlanWarningKind.RecoveryRunsProgram or PlanWarningKind.RecoveryUnnamed); + } + + [Fact] + public void The_cascade_is_never_asked_because_nothing_in_it_dies_with_the_process() + { + var facts = new FakeEndingFacts(); + var catalog = Separate(); + + var plan = new PlanBuilder(catalog.ReadAll(), catalog, facts) + .Build(new ServiceAction(ActionKind.ForceStop, "MRxSmb20", IncludeDependents: true)); + + Assert.Empty(plan.Problems); + Assert.Contains("MRxSmb20", facts.AskedRecovery); + Assert.DoesNotContain(facts.AskedRecovery, name => name is "LanmanWorkstation" or "SessionEnv" or "Netlogon"); + } + + private static OperationPlan Forced(FakeScmCatalog catalog, FakeEndingFacts facts) => + new PlanBuilder(catalog.ReadAll(), catalog, facts).Build(new ServiceAction(ActionKind.ForceStop, "Netlogon")); + + private static FakeScmCatalog Separate() => + Housed(("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", 6666), ("Netlogon", Netlogon)); + + private static FakeScmCatalog Sharing() => + Housed(("MRxSmb20", 4444), ("LanmanWorkstation", 5555), ("SessionEnv", Netlogon), ("Netlogon", Netlogon)); +} diff --git a/tests/Bws.Core.Tests/EndingCompanyRunTests.cs b/tests/Bws.Core.Tests/EndingCompanyRunTests.cs new file mode 100644 index 0000000..fb855ed --- /dev/null +++ b/tests/Bws.Core.Tests/EndingCompanyRunTests.cs @@ -0,0 +1,144 @@ +using Bws.Core.Planning; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// The last look at a process before it is ended, and what the step says when the manager brings the +/// entry straight back - stability report W-3 and W-6, package B2, 2026-09-30. +/// +/// Two things a plan cannot know when it is built, because both happen after the preview: a service +/// starting inside the process, or a dependant starting outside it. Until that day the ending went ahead +/// over both. And a step that ended a process watched only for Stopped, which a recovery restart at 0 ms +/// showed for 42-58 ms or not at all on the throwaway machine - so it waited the whole limit and reported +/// running out of time over a service already running again. +/// +/// Plans written out by hand, like ForcedRunTests, because these are about what the RUNNER does. +/// +public sealed class EndingCompanyRunTests +{ + private const int Held = 4812; + + [Fact] + public void A_service_that_moved_into_the_process_after_the_preview_holds_the_ending() + { + // Named to the double, so it lives in the process - every entry here is in 4812 unless told. + var control = new FakeScmControl().At("Newcomer", EntryStatus.Running); + + var result = Assert.Single(Run(control, Ending()).Results); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Newcomer", result.Error, StringComparison.Ordinal); + Assert.Empty(control.Ended); + } + + [Fact] + public void A_neighbour_the_plan_named_is_not_a_stranger() + { + var control = new FakeScmControl().At("Housemate", EntryStatus.Running); + + Run(control, Ending("Housemate")); + + Assert.Equal(Held, Assert.Single(control.Ended)); + } + + [Fact] + public void A_dependant_that_started_outside_the_process_after_the_preview_holds_the_ending() + { + var control = new FakeScmControl() + .DependedOnBy("Spooler", "Watcher") + .At("Watcher", EntryStatus.Running) + .RunningIn("Watcher", 5555); + + var result = Assert.Single(Run(control, Ending()).Results); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Contains("Watcher", result.Error, StringComparison.Ordinal); + Assert.Empty(control.Ended); + } + + [Fact] + public void A_stopped_dependant_and_one_inside_the_process_hold_nothing_back() + { + // The first needs nothing from a process. The second dies with it and the plan named it. + var control = new FakeScmControl() + .DependedOnBy("Spooler", "Sleeper", "Housemate") + .At("Sleeper", EntryStatus.Stopped) + .At("Housemate", EntryStatus.Running); + + Run(control, Ending("Housemate")); + + Assert.Equal(Held, Assert.Single(control.Ended)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void A_process_whose_company_cannot_be_read_is_not_ended(bool statuses) + { + var control = statuses + ? new FakeScmControl().RefusingStatuses(5) + : new FakeScmControl().RefusingDependents("Spooler", 5); + + var result = Assert.Single(Run(control, Ending()).Results); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.Equal(5, result.ErrorCode); + Assert.Empty(control.Ended); + } + + [Fact] + public void An_entry_back_in_a_new_process_is_said_at_once_rather_than_after_the_limit() + { + var control = new FakeScmControl() + .ComingBack("Spooler", new ServiceProgress(EntryStatus.Running, 0, TimeSpan.Zero, 5555)); + + var result = Assert.Single(Run(control, Ending()).Results); + + Assert.Equal(StepOutcome.Failed, result.Outcome); + Assert.True(result.StartedAgain); + Assert.Equal(0, result.ErrorCode); + Assert.Equal(EntryStatus.Running, result.Status); + Assert.Equal(5555, result.ProcessId.Value); + + // The first look already saw it - the fake clock has not moved, where the old watch ran the limit. + Assert.Equal(0, result.Milliseconds); + } + + [Fact] + public void An_entry_being_started_with_no_process_yet_is_watched_on() + { + // The manager marks it start pending before the new process exists - measured, for 31 and 45 ms. + var control = new FakeScmControl().ComingBack( + "Spooler", + new ServiceProgress(EntryStatus.StartPending, 0, TimeSpan.FromSeconds(2), 0), + new ServiceProgress(EntryStatus.Running, 0, TimeSpan.Zero, 5555)); + + var result = Assert.Single(Run(control, Ending()).Results); + + Assert.True(result.StartedAgain); + Assert.Equal(5555, result.ProcessId.Value); + } + + [Fact] + public void An_ending_that_is_seen_stopped_is_a_success_as_it_always_was() + { + var control = new FakeScmControl(); + + var result = Assert.Single(Run(control, Ending()).Results); + + Assert.Equal(StepOutcome.Succeeded, result.Outcome); + Assert.False(result.StartedAgain); + } + + private static OperationPlan Ending(params string[] housemates) => new() + { + Action = new ServiceAction(ActionKind.ForceStop, "Spooler", Immediate: true), + Steps = [new PlanStep("Spooler", "Spooler", StepOperation.Terminate, StepReason.Requested, ProcessId: Held, TakesWithIt: housemates)], + 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/Fakes/FakeEndingFacts.cs b/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs index d20f070..56b7d52 100644 --- a/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs +++ b/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs @@ -25,6 +25,59 @@ internal sealed class FakeEndingFacts : IEndingFactsReader private readonly Dictionary> _rights = []; private readonly Dictionary> _created = []; + private readonly Dictionary> _critical = []; + private readonly Dictionary>> _recovery = new(StringComparer.OrdinalIgnoreCase); + + /// Every entry whose recovery list was asked for, in order. + internal List AskedRecovery { get; } = []; + + /// Windows marks this process critical - ending it stops the machine. + internal FakeEndingFacts Critical(int processId) + { + _critical[processId] = Reading.Present(true); + return this; + } + + /// Nobody could read whether this process is critical. + internal FakeEndingFacts CriticalUnreadable(int processId, int errorCode = 5) + { + _critical[processId] = Reading.Denied(errorCode, "Access is denied."); + return this; + } + + /// What the manager does to this entry when its process dies, item by item. + internal FakeEndingFacts Recovering(string serviceName, params RecoveryAction[] actions) + { + _recovery[serviceName] = Reading>.Present(actions); + return this; + } + + /// The manager will not say what it does to this entry. + internal FakeEndingFacts RefusingRecovery(string serviceName, int errorCode = 5) + { + _recovery[serviceName] = Reading>.Denied(errorCode, "Access is denied."); + return this; + } + + /// + /// The entry has gone from the manager, which is what an entry uninstalled between the listing and the + /// question answers. + /// + internal FakeEndingFacts GoneFromTheManager(string serviceName) + { + _recovery[serviceName] = Reading>.Absent(); + return this; + } + + public Reading> ReadRecovery(string serviceName) + { + AskedRecovery.Add(serviceName); + + // An entry nobody scripted has no recovery at all - the ordinary answer is a list with no items. + return _recovery.TryGetValue(serviceName, out var recovery) + ? recovery + : Reading>.Present([]); + } /// Every process this was asked about, in order, so a test can see it was asked once. internal List Asked { get; } = []; @@ -72,6 +125,7 @@ public EndingFacts Read(int processId) _rights.TryGetValue(processId, out var rights) ? rights : Reading.Present(true), _created.TryGetValue(processId, out var created) ? created - : Reading.Present(ATimeLikeAnyOther)); + : Reading.Present(ATimeLikeAnyOther), + _critical.TryGetValue(processId, out var critical) ? critical : Reading.Present(false)); } } diff --git a/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs b/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs new file mode 100644 index 0000000..328cc66 --- /dev/null +++ b/tests/Bws.Core.Tests/Fakes/FakeScmControl.Company.cs @@ -0,0 +1,64 @@ +namespace Bws.Core.Tests.Fakes; + +/// +/// Who lives in a process and who depends on them, as the step that ends a process reads it just before it +/// does. Since 2026-09-30, stability report W-6, package B2. +/// +/// Answered from the same entries the rest of this double moves, so an entry the double stopped holds +/// no process here either, and a neighbour a plan's own step stopped never looks like a stranger. A second +/// double for these two questions would be two machines in one test that could disagree. +/// +internal sealed partial class FakeScmControl +{ + private readonly Dictionary> _dependents = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _dependentsRefusedWith = new(StringComparer.OrdinalIgnoreCase); + private int? _statusesRefusedWith; + + /// Entries that depend on this one, whatever they are doing. + internal FakeScmControl DependedOnBy(string serviceName, params string[] dependents) + { + _dependents[serviceName] = [.. dependents]; + return this; + } + + /// The manager will not enumerate anything. + internal FakeScmControl RefusingStatuses(int errorCode) + { + _statusesRefusedWith = errorCode; + return this; + } + + /// The manager will not say who depends on this entry. + internal FakeScmControl RefusingDependents(string serviceName, int errorCode) + { + _dependentsRefusedWith[serviceName] = errorCode; + return this; + } + + public Reading> ReadStatuses() + { + if (_statusesRefusedWith is { } refused) + { + return Reading>.Denied(refused, $"refused with {refused}"); + } + + return Reading>.Present( + [ + .. _entries.Select(pair => new ScmStatus( + pair.Key, + pair.Value.Status, + Held(pair.Value) == 0 ? Reading.Absent() : Reading.Present((int)Held(pair.Value)))) + ]); + } + + public Reading> ReadDependents(string serviceName) + { + if (_dependentsRefusedWith.TryGetValue(serviceName, out var refused)) + { + return Reading>.Denied(refused, $"refused with {refused}"); + } + + return Reading>.Present( + _dependents.TryGetValue(serviceName, out var dependents) ? dependents : []); + } +} diff --git a/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs b/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs index 543fc13..a5fdd22 100644 --- a/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs +++ b/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs @@ -20,7 +20,7 @@ namespace Bws.Core.Tests.Fakes; /// The ruler an entry made with keeps time by - the same one the runner /// under test is given. Only those entries need it. /// -internal sealed class FakeScmControl(IClock? clock = null) : IScmControl +internal sealed partial class FakeScmControl(IClock? clock = null) : IScmControl { private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); @@ -109,12 +109,28 @@ public ControlAnswer Terminate(int processId, long? createdAt) // notice a death this double never modelled. entry.Moving = false; entry.AfterRequest = null; + + if (entry.ComesBack is { } back) + { + entry.AfterRequest = back; + entry.Moving = true; + } } } return ControlAnswer.Done(); } + /// + /// An entry the manager starts again once its process dies - a recovery list saying "restart the + /// service" - handing out these readings after the ending, the last one repeating. Since 2026-09-30. + /// + internal FakeScmControl ComingBack(string serviceName, params ServiceProgress[] readings) + { + Entry(serviceName).ComesBack = new Queue(readings); + return this; + } + /// Which process an entry runs in, for the tests that end one. internal FakeScmControl RunningIn(string serviceName, int processId) { @@ -401,6 +417,9 @@ internal void AskedAgain() internal Queue? AfterRequest { get; set; } + /// What the entry says once its process has been ended, when the manager brings it back. + internal Queue? ComesBack { get; set; } + internal bool Moving { get; set; } internal int? RequestRefusedWith { get; set; } diff --git a/tests/Bws.Gui.Tests/AftermathWordsGuards.cs b/tests/Bws.Gui.Tests/AftermathWordsGuards.cs new file mode 100644 index 0000000..1ce51a2 --- /dev/null +++ b/tests/Bws.Gui.Tests/AftermathWordsGuards.cs @@ -0,0 +1,68 @@ +using Bws.Core; +using Bws.Core.Planning; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// The window's sentences for what ending a process sets off - stability report W-3, package B2, 2026-09-30. +/// +/// Both switches these arrive through end in a refusal, so a kind without an arm is an exception on the +/// sheet somebody is about to press. These pin the key each shape reaches, in both numbers. +/// +public sealed class AftermathWordsGuards +{ + [Theory] + [InlineData(PlanWarningKind.RecoveryRestarts, "gui.plan.warning.recoveryRestarts")] + [InlineData(PlanWarningKind.RecoveryRunsProgram, "gui.plan.warning.recoveryRunsProgram")] + [InlineData(PlanWarningKind.RecoveryUnnamed, "gui.plan.warning.recoveryUnnamed")] + public void Each_warning_has_its_own_sentence_in_both_numbers(PlanWarningKind kind, string key) + { + Assert.Equal( + Texts.Of($"{key}.one", "Spooler", 1, "Spooler"), + PlanWords.Describe(new PlanWarning(kind, "Spooler", ["Spooler"]))); + + Assert.Equal( + Texts.Of($"{key}.many", "Spooler", 2, "Spooler, Fax"), + PlanWords.Describe(new PlanWarning(kind, "Spooler", ["Spooler", "Fax"]))); + } + + [Fact] + public void The_three_refusals_each_have_a_sentence() + { + Assert.Equal( + Texts.Of("gui.plan.problem.processIsCritical", "RpcSs"), + PlanWords.Describe(new PlanProblem(PlanProblemKind.ProcessIsCritical, "RpcSs", []))); + + Assert.Equal( + Texts.Of("gui.plan.problem.recoveryRestartsComputer.one", "RpcSs", "RpcSs"), + PlanWords.Describe(new PlanProblem(PlanProblemKind.RecoveryRestartsComputer, "RpcSs", ["RpcSs"]))); + + Assert.Equal( + Texts.Of("gui.plan.problem.aftermathUnreadable.process", "Spooler"), + PlanWords.Describe(new PlanProblem(PlanProblemKind.AftermathUnreadable, "Spooler", []))); + + Assert.Equal( + Texts.Of("gui.plan.problem.aftermathUnreadable.many", "Spooler", "Fax, Spooler"), + PlanWords.Describe(new PlanProblem(PlanProblemKind.AftermathUnreadable, "Spooler", ["Fax", "Spooler"]))); + } + + [Fact] + public void An_ending_the_manager_undid_at_once_names_the_new_process() + { + var said = PlanWords.Describe(new StepResult + { + Step = new PlanStep("Spooler", "Print Spooler", StepOperation.Terminate, StepReason.Requested, ProcessId: 4812), + Outcome = StepOutcome.Failed, + SkippedBecause = null, + Status = EntryStatus.Running, + ProcessId = Reading.Present(5555), + ErrorCode = 0, + Error = "ours", + Milliseconds = 16, + StartedAgain = true + }); + + Assert.Equal(Texts.Of("gui.plan.failure.startedAgain", "Spooler", 5555), said); + } +} diff --git a/tests/Bws.Gui.Tests/CountedWords.cs b/tests/Bws.Gui.Tests/CountedWords.cs index bb3f680..e6893ba 100644 --- a/tests/Bws.Gui.Tests/CountedWords.cs +++ b/tests/Bws.Gui.Tests/CountedWords.cs @@ -103,6 +103,10 @@ internal static class CountedWords // 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", + // One more on 2026-09-30, from the refusal of a forced stop over a critical process: "Windows + // marks the process {0} runs in as critical" - a service name, then the verb above, then a + // preposition that merely ends in s. Nothing here is counted. + "as", // 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.Integration.Tests/EndingFactsContractTests.cs b/tests/Bws.Integration.Tests/EndingFactsContractTests.cs index eb9808f..9e26fe9 100644 --- a/tests/Bws.Integration.Tests/EndingFactsContractTests.cs +++ b/tests/Bws.Integration.Tests/EndingFactsContractTests.cs @@ -56,6 +56,10 @@ public void This_process_can_be_ended_and_started_when_PowerShell_says_it_did() CultureInfo.InvariantCulture); Assert.Equal(theirs, facts.Created.Value); + + // A process that can be ended and is not one Windows needs - read, not assumed (2026-09-30). + Assert.Equal(ReadOutcome.Present, facts.Critical.Outcome); + Assert.False(facts.Critical.Value); } [Fact] @@ -98,5 +102,7 @@ public void The_idle_process_number_reads_as_no_such_process_and_not_as_a_refusa Assert.Equal(ReadOutcome.Absent, facts.Created.Outcome); Assert.Equal(0, facts.Created.ErrorCode); + + Assert.Equal(ReadOutcome.Absent, facts.Critical.Outcome); } } diff --git a/tests/Bws.Integration.Tests/RecoveryContractTests.cs b/tests/Bws.Integration.Tests/RecoveryContractTests.cs new file mode 100644 index 0000000..c93c8ec --- /dev/null +++ b/tests/Bws.Integration.Tests/RecoveryContractTests.cs @@ -0,0 +1,61 @@ +using Bws.Core; + +namespace Bws.Integration.Tests; + +/// +/// The recovery list the plan that ends a process reads, put to the real manager and held against sc.exe. +/// +/// RpcSs is the specimen, because every Windows since Vista ships it with "restart the computer" as its +/// recovery - measured on the owner's machine on 2026-09-30, and it is the item the plan refuses on. The +/// comparison is on what sc.exe can print: it prints no line for an item that does nothing, nor for the +/// undocumented type the same measurement found on Schedule, so those two are left out of both sides. +/// +/// Critical processes are not a specimen here, and that is measured rather than skipped: under a +/// restricted token wininit.exe refuses the question with 5, so a test built on it would fail wherever the +/// suite runs without elevation. The canary in tools/scm-probe/recovery-probe.ps1 carries that half. +/// +/// Read-only. Nothing here opens anything with a right that could change it. +/// +public sealed class RecoveryContractTests +{ + [Fact] + public void The_recovery_list_of_RpcSs_reads_as_sc_exe_prints_it() + { + var ours = new WindowsEndingFactsReader().ReadRecovery("RpcSs"); + + Assert.Equal(ReadOutcome.Present, ours.Outcome); + + // By tokens rather than by labels: an action is "WORDS -- Delay = N", and the first of them shares + // its line with the FAILURE_ACTIONS label and a colon. + var theirs = CommandLineTool.ServiceControl("qfailure", "RpcSs", "5000").StandardOutput + .Split('\n') + .Where(line => line.Contains("-- Delay", StringComparison.Ordinal)) + .Select(line => line[..line.IndexOf("--", StringComparison.Ordinal)]) + .Select(before => before[(before.LastIndexOf(':') + 1)..].Trim()) + .ToArray(); + + string[] spoken = + [ + .. ours.Value! + .Where(action => action is not (RecoveryAction.Nothing or RecoveryAction.Unnamed)) + .Select(action => action switch + { + RecoveryAction.RestartService => "RESTART", + RecoveryAction.RunProgram => "RUN PROCESS", + _ => "REBOOT" + }) + ]; + + Assert.NotEmpty(theirs); + Assert.Equal(theirs, spoken); + } + + [Fact] + public void A_service_that_is_not_there_has_no_recovery_rather_than_a_refused_one() + { + var ours = new WindowsEndingFactsReader().ReadRecovery("BwsNoSuchService-" + Guid.NewGuid().ToString("N")); + + Assert.Equal(ReadOutcome.Absent, ours.Outcome); + Assert.Equal(0, ours.ErrorCode); + } +}