From d69c610eb168cba064c82259462f9af7c2200ada Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 30 Sep 2026 14:30:29 +0200 Subject: [PATCH] Say when Windows starts an ended service again, before and after the run The recovery list is now read with the delay of every item. The warning that ending a process makes Windows start a service again names the delays beside each entry ("Spooler (5 s later)", "W32Time (60 s or 120 s later)"), every different one, because which item runs depends on a failure count Windows does not hand out. After a run that ended a process, the report says who comes back and when: the terminal in a paragraph under the steps, the window as a second sentence of the notice. An entry that reported Stopped before the ending, one Windows started again at once and one the run started again itself are left out. The rule is Microsoft's own - a service fails when its process ends without reporting Stopped - so a neighbour still in StopPending when the process went is named. No field is added to the JSON - the delays reach it only inside the message. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + src/Bws.Cli/PlanText.Aftermath.cs | 65 +++++- src/Bws.Cli/PlanText.cs | 2 + src/Bws.Cli/Resources/cli.en.json | 6 +- src/Bws.Core/EndingFacts.cs | 31 ++- src/Bws.Core/Planning/Aftermath.cs | 106 ++++++++- src/Bws.Core/Planning/PlanRun.cs | 13 ++ src/Bws.Core/Planning/PlanWarnings.cs | 29 +++ src/Bws.Core/ScmDetailReader.Recovery.cs | 33 +-- .../WindowsEndingFactsReader.Recovery.cs | 8 +- src/Bws.Gui/Resources/gui.en.json | 6 + src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs | 64 +++++- src/Bws.Gui/ViewModels/Planned.cs | 2 +- tests/Bws.Cli.Tests/AftermathSentenceTests.cs | 4 +- tests/Bws.Cli.Tests/ComingBackTextTests.cs | 108 +++++++++ tests/Bws.Core.Tests/AftermathTests.cs | 23 ++ tests/Bws.Core.Tests/ComingBackTests.cs | 210 ++++++++++++++++++ tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs | 20 +- tests/Bws.Gui.Tests/ComingBackWordsGuards.cs | 118 ++++++++++ .../RecoveryContractTests.cs | 25 ++- 20 files changed, 833 insertions(+), 50 deletions(-) create mode 100644 tests/Bws.Cli.Tests/ComingBackTextTests.cs create mode 100644 tests/Bws.Core.Tests/ComingBackTests.cs create mode 100644 tests/Bws.Gui.Tests/ComingBackWordsGuards.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc6106..1b7b12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,19 @@ is not part of this repository. in a terminal. A force stop refused for the same reason offers the same, and it is the way forward from that sheet. Nothing is carried out until you press the button at the foot of the plan, and a restart as administrator keeps the choice. +- After a plan that ended a process - `bws kill`, or Force stop in the window - the report says which + services Windows will start again by itself and how long after the ending, as their recovery + actions say (`sc.exe qfailure` shows them). The step that ended the process is reported done the + moment it happens, and a service set to restart after a minute was back a minute later with nothing + on screen saying so. A forced restart that started everything again itself adds nothing. ### Changed +- The warning that Windows starts a service again once its process is ended now says when, beside + every name: "Spooler (5 s later)", or "W32Time (60 s or 120 s later)" when the recovery actions + name several delays - which of them applies depends on how often the service has failed, and + Windows does not say. With `--json` this is in the `message` of the warning. No field was added. + - Interrupt goes grey once pressed, and the line at the top of the plan says the run was interrupted and is finishing the step in flight. Until now pressing it left no trace on the screen. - Closing the window while a plan is being carried out now shows the same thing as Interrupt and diff --git a/src/Bws.Cli/PlanText.Aftermath.cs b/src/Bws.Cli/PlanText.Aftermath.cs index 4eb4cb9..d090bcc 100644 --- a/src/Bws.Cli/PlanText.Aftermath.cs +++ b/src/Bws.Cli/PlanText.Aftermath.cs @@ -1,10 +1,13 @@ +using System.Globalization; +using System.Text; 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). +/// since 2026-09-30 (stability report W-3, package B2), and from the same day's backlog 501 the delays of +/// the restarts and the line after a run naming who comes back. /// /// 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 @@ -15,9 +18,14 @@ internal static partial class PlanText { private static string Aftermath(PlanWarning warning) => warning.Kind switch { + // WHEN, BESIDE EVERY NAME, since 2026-09-30 (backlog 501) - and the bare name for the singular's + // command at the end, which a person copies. PlanWarningKind.RecoveryRestarts => Texts.Of( Count("cli.plan.warning.recoveryRestarts", warning), - warning.ServiceName, warning.Related.Count, Join(warning.Related)), + warning.ServiceName, + warning.Related.Count, + Join([.. warning.Related.Select(name => Later(name, warning.Restarts))]), + warning.Related.FirstOrDefault() ?? warning.ServiceName), PlanWarningKind.RecoveryRunsProgram => Texts.Of( Count("cli.plan.warning.recoveryRunsProgram", warning), @@ -56,4 +64,57 @@ internal static partial class PlanText _ => throw new ArgumentOutOfRangeException( nameof(problem), problem.Kind, EquivalentCommand.Unhandled) }; + + /// + /// The line after a run that ended a process, naming who Windows starts again and when - nothing when + /// nobody comes back. Since 2026-09-30, backlog 501: decides who. + /// + /// Its own paragraph under the steps, beside the line about a manager that outran the limit, + /// because both are about what the steps above did not show. The warnings repeated further down say + /// what the plan expected, and this says what is still to come once the run is over. + /// + private static void AddComingBack(StringBuilder text, PlanRun? run) + { + if (run?.ComingBack is not { Count: > 0 } back) + { + return; + } + + text.AppendLine(); + text.AppendLine(back.Count == 1 + ? Texts.Of("cli.run.comesBack.one", run.Plan.Action.ServiceName, Later(back[0].ServiceName, back)) + : Texts.Of( + "cli.run.comesBack.many", + run.Plan.Action.ServiceName, + back.Count, + Join([.. back.Select(one => Later(one.ServiceName, back))]))); + } + + /// + /// An entry's name with the delays its recovery list restarts it after, or the bare name when there are + /// none to say - a warning built without them reads as it did before they were read. + /// + private static string Later(string serviceName, IReadOnlyList restarts) => + restarts.FirstOrDefault(one => string.Equals(one.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase)) + is { After.Count: > 0 } restart + ? Texts.Of("cli.plan.recovery.later", serviceName, Delays(restart.After)) + : serviceName; + + /// + /// "60 s", or "1 s, 2 s, 4 s, 8 s or 16 s" - every delay, because which item runs depends on a count of + /// failures nothing hands out (). + /// + private static string Delays(IReadOnlyList after) => after.Count == 1 + ? Seconds(after[0]) + : Texts.Of( + "cli.plan.recovery.either", + string.Join(", ", after.Take(after.Count - 1).Select(Seconds)), + Seconds(after[^1])); + + /// + /// Always in seconds, with one decimal place - the owner's decision said "after N s", and 100 ms reads + /// as "0.1 s" rather than switching units inside one list. + /// + private static string Seconds(TimeSpan delay) => + Texts.Of("cli.run.took.seconds", delay.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)); } diff --git a/src/Bws.Cli/PlanText.cs b/src/Bws.Cli/PlanText.cs index ade59db..300b59a 100644 --- a/src/Bws.Cli/PlanText.cs +++ b/src/Bws.Cli/PlanText.cs @@ -102,6 +102,8 @@ private static string Render( Took((long)run!.Ceiling.TotalMilliseconds))); } + AddComingBack(text, run); + if (plan.Warnings.Count > 0) { text.AppendLine(); diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json index fba5db2..168044a 100644 --- a/src/Bws.Cli/Resources/cli.en.json +++ b/src/Bws.Cli/Resources/cli.en.json @@ -90,8 +90,10 @@ "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.restartOnlyStarts": "{0} is not running, so restarting it only starts it.", - "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.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 {3}", "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.recovery.later": "{0} ({1} later)", + "cli.plan.recovery.either": "{0} or {1}", "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}", @@ -102,6 +104,8 @@ "cli.run.abandoned": "Leaving the rest undone. The report still follows, so you can see what was changed. Another Ctrl+C ends this without it.", "cli.run.wasInterrupted": "This run was interrupted. Steps that were never attempted say so above.", "cli.run.outranTheCeiling": "{0}: the manager took {1} to answer the request, longer than the --timeout of {2}. That switch limits how long this tool waits without progress once the manager has accepted a request. It cannot cap the manager's own answer, which takes this long when a service never reports itself to it.", + "cli.run.comesBack.one": "Windows starts {1} again by itself after the process behind {0} was ended - its recovery actions say so. The stop may not last.", + "cli.run.comesBack.many": "Windows starts {1} entries again by itself after the process behind {0} was ended - their recovery actions say so: {2}. The stop may not last.", "cli.run.putBack.heading": "To put the machine back the way this run found it, in this order:", "cli.run.putBack.line": " {0}", diff --git a/src/Bws.Core/EndingFacts.cs b/src/Bws.Core/EndingFacts.cs index a1494bc..706b120 100644 --- a/src/Bws.Core/EndingFacts.cs +++ b/src/Bws.Core/EndingFacts.cs @@ -79,13 +79,32 @@ public static EndingFacts NobodyAsked() => } /// -/// One thing the manager does when an entry's process dies without the entry saying it stopped. +/// One item of an entry's recovery list, as the manager keeps it: what it does, and how long after the +/// failure it does it. +/// +/// The delay is kept since 2026-09-30, backlog 501, and until that day it was read and dropped. +/// A restart after a minute is a different sentence from one at once - the step that ended the process +/// reports success, the run reports complete, and the service is back a minute later with nobody told. +/// The plan now says when, and so does the report after a run. Measured on the owner's machine before the +/// change: of 204 services with a restart in the list, 123 name more than one delay, so the sentence names +/// every one of them rather than guessing which comes. +/// +/// What this item does. +/// +/// How long the manager waits after the failure before doing it - Microsoft's SC_ACTION.Delay, in +/// milliseconds there. Zero is an ordinary value: five services on that machine restart at once. +/// +public readonly record struct RecoveryItem(RecoveryAction Action, TimeSpan Delay); + +/// +/// What one item of the recovery list does when an entry's process dies without the entry saying it +/// stopped - the kind of an item, carries it with its delay. /// /// 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. +/// full recovery list 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 @@ -150,7 +169,7 @@ public interface IEndingFactsReader /// 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); + Reading> ReadRecovery(string serviceName); } /// @@ -166,6 +185,6 @@ internal sealed class NobodyToAsk : IEndingFactsReader public EndingFacts Read(int processId) => EndingFacts.NobodyAsked(); - public Reading> ReadRecovery(string serviceName) => - Reading>.NotRead(); + public Reading> ReadRecovery(string serviceName) => + Reading>.NotRead(); } diff --git a/src/Bws.Core/Planning/Aftermath.cs b/src/Bws.Core/Planning/Aftermath.cs index 015bf3c..0f4ebda 100644 --- a/src/Bws.Core/Planning/Aftermath.cs +++ b/src/Bws.Core/Planning/Aftermath.cs @@ -17,7 +17,7 @@ namespace Bws.Core.Planning; 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); + 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 @@ -94,14 +94,114 @@ .. sharing.Prepend(target) /// /// 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. + /// + /// The first carries WHEN since 2026-09-30 (backlog 501) - , the + /// names in taken from the same list in the same order. /// internal static void AddWarnings(List warnings, ScmEntry target, IReadOnlyList recovery) { - Warn(warnings, target, Having(recovery, RecoveryAction.RestartService), PlanWarningKind.RecoveryRestarts); + var restarts = Restarts(recovery); + + if (restarts.Count > 0) + { + warnings.Add(new PlanWarning( + PlanWarningKind.RecoveryRestarts, target.ServiceName, [.. restarts.Select(one => one.ServiceName)]) + { + Restarts = restarts + }); + } + Warn(warnings, target, Having(recovery, RecoveryAction.RunProgram), PlanWarningKind.RecoveryRunsProgram); Warn(warnings, target, Having(recovery, RecoveryAction.Unnamed), PlanWarningKind.RecoveryUnnamed); } + /// + /// Every entry with a restart anywhere in its list, and the different delays of those restarts in the + /// order the list gives them - WSearch names 30 s five times and comes out as one. + /// + private static List Restarts(IReadOnlyList recovery) => + [ + .. recovery + .Where(one => one.Actions.IsPresent) + .Select(one => new RecoveryRestart( + one.ServiceName, + [.. one.Actions.Value!.Where(item => item.Action == RecoveryAction.RestartService).Select(item => item.Delay).Distinct()])) + .Where(one => one.After.Count > 0) + ]; + + /// + /// Who of the entries the plan warned about comes back after this run, and nothing unless the run ended + /// the process - asked from what the run recorded, never from the machine again. + /// + /// An entry comes back when it died with the process and this run did not start it afterwards. + /// Died with it: Microsoft counts a service as failed when its process ends WITHOUT it reporting Stopped + /// (SERVICE_FAILURE_ACTIONSW, read 2026-09-30), so a neighbour whose own polite stop arrived, or + /// found it stopped, sets nothing off - and the entry itself is left out when Windows started it again at + /// once, because its own line already says so. Started afterwards: a restart that put everything back + /// has nothing to announce, while a start that failed or was never tried leaves the entry for its list. + /// + /// NOT 's count of who the ending took down, and that was checked on a second + /// case before this was written. That count gives a neighbour whose polite stop timed out to the stop, + /// which is right for the way back. For the recovery list it is wrong: the entry was still in StopPending, + /// never reported Stopped, and by the documented rule fails with the process. + /// + /// What the list said when the plan was built is what this repeats - a list changed between the + /// preview and the run is not read again, and the sentence says "its recovery actions say so" of the + /// reading it has. + /// + internal static IReadOnlyList ComingBack(OperationPlan plan, IReadOnlyList results) + { + var restarts = plan.Warnings.FirstOrDefault(warning => warning.Kind == PlanWarningKind.RecoveryRestarts)?.Restarts ?? []; + + if (restarts.Count == 0 || EndedAt(results) is not { } at) + { + return []; + } + + var ending = results[at]; + + return + [ + .. restarts.Where(one => + DiedWith(one.ServiceName, ending, results.Take(at)) + && !StartedAfter(one.ServiceName, results.Skip(at + 1))) + ]; + } + + /// + /// Where the step that ended the process is, when it ended it - arrived, timed out watching the entry + /// after the call worked, or answered by Windows starting the entry again at once. + /// + private static int? EndedAt(IReadOnlyList results) + { + for (var index = 0; index < results.Count; index++) + { + if (results[index] is { Step.Operation: StepOperation.Terminate } result + && (result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut || result.StartedAgain)) + { + return index; + } + } + + return null; + } + + private static bool DiedWith(string serviceName, StepResult ending, IEnumerable before) => + Is(ending, serviceName) + ? !ending.StartedAgain + : !before.Any(result => Is(result, serviceName) + && result.Step.Operation == StepOperation.Stop + && (result.Outcome == StepOutcome.Succeeded || result.SkippedBecause == SkipReason.AlreadyThere)); + + private static bool StartedAfter(string serviceName, IEnumerable after) => + after.Any(result => Is(result, serviceName) + && result.Step.Operation == StepOperation.Start + && (result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut + || result.SkippedBecause == SkipReason.AlreadyThere)); + + private static bool Is(StepResult result, string serviceName) => + string.Equals(result.Step.ServiceName, serviceName, StringComparison.OrdinalIgnoreCase); + private static void Warn(List warnings, ScmEntry target, List named, PlanWarningKind kind) { if (named.Count > 0) @@ -115,5 +215,5 @@ private static void Warn(List warnings, ScmEntry target, List private static List Having(IReadOnlyList recovery, RecoveryAction action) => - [.. recovery.Where(one => one.Actions.IsPresent && one.Actions.Value!.Contains(action)).Select(one => one.ServiceName)]; + [.. recovery.Where(one => one.Actions.IsPresent && one.Actions.Value!.Any(item => item.Action == action)).Select(one => one.ServiceName)]; } diff --git a/src/Bws.Core/Planning/PlanRun.cs b/src/Bws.Core/Planning/PlanRun.cs index b54916e..0dae488 100644 --- a/src/Bws.Core/Planning/PlanRun.cs +++ b/src/Bws.Core/Planning/PlanRun.cs @@ -267,6 +267,19 @@ private static StepOperation Aim(StepOperation operation) => /// there, beside the code it explains. /// public IReadOnlyList Reversal => NetEffect.Of(Results); + + /// + /// The entries this run left down that Windows starts again by itself, and when - their recovery + /// lists said so when the plan was built. Empty unless a process was ended. + /// + /// Here since 2026-09-30, backlog 501, and the property exists because a report can be true and + /// still leave somebody wrong. Measured on the throwaway machine the day before: a forced stop + /// with a restart after 3000 ms reported the step succeeded, the run complete and exit code 0 - and + /// three seconds later the service was running. Waiting that long was ruled out (restarts of a minute + /// and two are common), so the report says it instead. A judgement about the run, decided once for both + /// interfaces, the way is. says who. + /// + public IReadOnlyList ComingBack => Aftermath.ComingBack(Plan, Results); } /// diff --git a/src/Bws.Core/Planning/PlanWarnings.cs b/src/Bws.Core/Planning/PlanWarnings.cs index 1f84b03..cd1d61f 100644 --- a/src/Bws.Core/Planning/PlanWarnings.cs +++ b/src/Bws.Core/Planning/PlanWarnings.cs @@ -225,4 +225,33 @@ internal PlanWarning(PlanWarningKind kind, string serviceName) : this(kind, serviceName, []) { } + + /// + /// When each entry in comes back, for + /// and empty for every other kind - the way is zero for every + /// refusal but one. Built from the same reading as the names, in the same order. + /// + /// Here rather than on the step that ends the process, since 2026-09-30 (backlog 501), because + /// the sentence before the run is made from the warning alone, and the line after it + /// () can read the warning off the plan it ran. A copy on the step + /// would be a second answer that nothing in the run itself uses. + /// + /// Not in the machine readable output - the owner's decision of that day. The delays reach a + /// script only inside the sentence. + /// + public IReadOnlyList Restarts { get; init; } = []; } + +/// +/// An entry the manager starts again by itself once its process is ended, and the delays its recovery +/// list names for that - every different one, in the order of the list. +/// +/// Every delay rather than one, and that is the documented behaviour rather than caution. The +/// manager counts failures since the machine started and runs item N for failure N, repeating the last, +/// and forgets the count after the reset period (SERVICE_FAILURE_ACTIONSW on learn.microsoft.com, +/// read 2026-09-30). The page names no call that hands the count out, so which of these delays applies is +/// not known from outside, and the sentence says "60 s or 120 s later". +/// +/// The entry, by the name the manager knows it by. +/// Never empty - an entry without a restart in its list is not one of these. +public sealed record RecoveryRestart(string ServiceName, IReadOnlyList After); diff --git a/src/Bws.Core/ScmDetailReader.Recovery.cs b/src/Bws.Core/ScmDetailReader.Recovery.cs index 2781dba..2aad84a 100644 --- a/src/Bws.Core/ScmDetailReader.Recovery.cs +++ b/src/Bws.Core/ScmDetailReader.Recovery.cs @@ -16,23 +16,25 @@ namespace Bws.Core; internal static partial class ScmDetailReader { /// - /// Every item of the entry's recovery list, in order, reduced to what the plan needs: which kind. + /// Every item of the entry's recovery list, in order, with the kind and the delay of each. /// - /// 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. + /// The delays are kept since 2026-09-30 (backlog 501), and the reset period is still read and + /// dropped on purpose. A delay changes nothing about whether a restart comes, only when - and + /// "when" is what a person reading "succeeded" needed to hear. The reset period only says when the + /// manager forgets earlier failures, and which item runs depends on a failure count no documented call + /// hands out, so it would decide nothing here. 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) + 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); + ? Reading>.Absent() + : Refused>(refusal); } var buffer = new byte[needed]; @@ -45,7 +47,7 @@ internal static unsafe Reading> ReadRecovery(SafeH service, SERVICE_CONFIG.SERVICE_CONFIG_FAILURE_ACTIONS, new Span(pinned, buffer.Length), out _)) { - return Refused>(Marshal.GetLastWin32Error()); + return Refused>(Marshal.GetLastWin32Error()); } return Items(pinned, buffer.Length); @@ -61,11 +63,11 @@ internal static unsafe Reading> ReadRecovery(SafeH /// 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) + private static unsafe Reading> Items(byte* block, int length) { if (length < sizeof(SERVICE_FAILURE_ACTIONSW)) { - return Reading>.Absent(); + return Reading>.Absent(); } var header = (SERVICE_FAILURE_ACTIONSW*)block; @@ -73,24 +75,25 @@ private static unsafe Reading> Items(byte* block, if (count == 0) { - return Reading>.Present([]); + 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); + return Refused>((int)WIN32_ERROR.ERROR_INVALID_DATA); } - var items = new RecoveryAction[count]; + var items = new RecoveryItem[count]; for (var index = 0; index < count; index++) { - items[index] = Kind(header->lpsaActions[index].Type); + var item = header->lpsaActions[index]; + items[index] = new RecoveryItem(Kind(item.Type), TimeSpan.FromMilliseconds(item.Delay)); } - return Reading>.Present(items); + return Reading>.Present(items); } /// diff --git a/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs b/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs index f5a0d9e..21013b8 100644 --- a/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs +++ b/src/Bws.Core/WindowsEndingFactsReader.Recovery.cs @@ -14,7 +14,7 @@ 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) + public Reading> ReadRecovery(string serviceName) { using var manager = PInvoke.OpenSCManager( lpMachineName: null!, @@ -37,8 +37,8 @@ public Reading> ReadRecovery(string serviceName) /// 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) => + private static Reading> Unanswered(int code) => code == (int)WIN32_ERROR.ERROR_SERVICE_DOES_NOT_EXIST - ? Reading>.Absent() - : Reading>.Denied(code, ManagerTerms.Describe(code)); + ? Reading>.Absent() + : Reading>.Denied(code, ManagerTerms.Describe(code)); } diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json index b06ca53..20b55b9 100644 --- a/src/Bws.Gui/Resources/gui.en.json +++ b/src/Bws.Gui/Resources/gui.en.json @@ -233,6 +233,9 @@ "gui.plan.notice.running": "Carrying this out now. Interrupting stops the steps that have not started - steps that give something back still run.", "gui.plan.notice.done.one": "Done. The entry is where you asked.", "gui.plan.notice.done.many": "Done. All {0} entries are where you asked.", + "gui.plan.notice.andAfter": "{0} {1}", + "gui.plan.notice.comesBack.one": "Windows starts {1} again by itself after the process behind {0} was ended - its recovery actions say so. The stop may not last.", + "gui.plan.notice.comesBack.many": "Windows starts {1} entries again by itself after the process behind {0} was ended - their recovery actions say so: {2}. The stop may not last.", "gui.plan.notice.partly.one": "The entry is not where you asked. What did not is below.", "gui.plan.notice.partly.many": "{0} of {1} entries are where you asked. What did not is below.", "gui.plan.notice.nothingRun": "Nothing was carried out. The line at the bottom of the window says why.", @@ -286,6 +289,9 @@ "gui.plan.warning.restartOnlyStarts": "{0} is not running, so restarting it only starts 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.recovery.later": "{0} ({1} later)", + "gui.plan.recovery.either": "{0} or {1}", + "gui.plan.recovery.seconds": "{0} s", "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.", diff --git a/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs b/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs index 5c48a9e..73769e2 100644 --- a/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs +++ b/src/Bws.Gui/ViewModels/PlanWords.Aftermath.cs @@ -1,10 +1,12 @@ +using System.Globalization; 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). +/// since 2026-09-30 (stability report W-3, package B2), and from the same day's backlog 501 the delays of +/// the restarts and the sentence after a run naming who comes back. /// /// 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. @@ -16,9 +18,10 @@ internal static partial class PlanWords { private static string Aftermath(PlanWarning warning) => warning.Kind switch { + // WHEN, BESIDE EVERY NAME, since 2026-09-30 (backlog 501). 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)), + ? Texts.Of("gui.plan.warning.recoveryRestarts.one", warning.ServiceName, warning.Related.Count, Later(warning.Related, warning.Restarts)) + : Texts.Of("gui.plan.warning.recoveryRestarts.many", warning.ServiceName, warning.Related.Count, Later(warning.Related, warning.Restarts)), PlanWarningKind.RecoveryRunsProgram => warning.Related.Count == 1 ? Texts.Of("gui.plan.warning.recoveryRunsProgram.one", warning.ServiceName, warning.Related.Count, Listed(warning.Related)) @@ -54,4 +57,59 @@ internal static partial class PlanWords _ => throw new ArgumentOutOfRangeException( nameof(problem), problem.Kind, EquivalentCommand.Unhandled) }; + + /// + /// How a finished run reads, followed by who Windows starts again and when - the sentence alone when + /// nobody comes back. Since 2026-09-30, backlog 501: decides who. + /// + /// A second sentence in the notice rather than a section of its own, because it qualifies the + /// first: "Done. The entry is where you asked." is true when it is read and stops being true a minute + /// later, and the sentence saying so belongs directly after it. No new element, so nothing on the sheet + /// moves - the notice already wraps. + /// + internal static string ThenComingBack(string reported, BulkRun run) + { + string[] back = [.. run.Runs.Select(ComingBack).Where(line => line.Length > 0)]; + + return back.Length == 0 + ? reported + : Texts.Of("gui.plan.notice.andAfter", reported, string.Join(" ", back)); + } + + private static string ComingBack(PlanRun run) + { + var back = run.ComingBack; + + return back.Count switch + { + 0 => string.Empty, + 1 => Texts.Of("gui.plan.notice.comesBack.one", run.Plan.Action.ServiceName, Later([back[0].ServiceName], back)), + _ => Texts.Of("gui.plan.notice.comesBack.many", run.Plan.Action.ServiceName, back.Count, Later([.. back.Select(one => one.ServiceName)], back)) + }; + } + + /// + /// The names with the delays their recovery lists restart them after - a name with none to say stays + /// bare, so a warning built without them reads as it did before they were read. + /// + private static string Later(IReadOnlyList names, IReadOnlyList restarts) => + Listed([.. names.Select(name => + restarts.FirstOrDefault(one => string.Equals(one.ServiceName, name, StringComparison.OrdinalIgnoreCase)) + is { After.Count: > 0 } restart + ? Texts.Of("gui.plan.recovery.later", name, Delays(restart.After)) + : name)]); + + /// + /// "60 s", or "1 s, 2 s, 4 s, 8 s or 16 s" - every delay, because which item runs depends on a count of + /// failures nothing hands out (). Always seconds, one decimal place. + /// + private static string Delays(IReadOnlyList after) => after.Count == 1 + ? Seconds(after[0]) + : Texts.Of( + "gui.plan.recovery.either", + string.Join(", ", after.Take(after.Count - 1).Select(Seconds)), + Seconds(after[^1])); + + private static string Seconds(TimeSpan delay) => + Texts.Of("gui.plan.recovery.seconds", delay.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture)); } diff --git a/src/Bws.Gui/ViewModels/Planned.cs b/src/Bws.Gui/ViewModels/Planned.cs index 8a9a328..5d90791 100644 --- a/src/Bws.Gui/ViewModels/Planned.cs +++ b/src/Bws.Gui/ViewModels/Planned.cs @@ -364,7 +364,7 @@ public IReadOnlyList Warnings ? _because.Length == 0 ? Texts.Of("gui.plan.notice.notYet") : Texts.Of("gui.plan.notice.because", _because, Texts.Of("gui.plan.notice.notYet")) - : Reported(run); + : PlanWords.ThenComingBack(Reported(run), run); /// /// How a finished run reads, in the singular and in the plural. diff --git a/tests/Bws.Cli.Tests/AftermathSentenceTests.cs b/tests/Bws.Cli.Tests/AftermathSentenceTests.cs index 21d920b..1f653f1 100644 --- a/tests/Bws.Cli.Tests/AftermathSentenceTests.cs +++ b/tests/Bws.Cli.Tests/AftermathSentenceTests.cs @@ -18,8 +18,10 @@ public sealed class AftermathSentenceTests [InlineData(PlanWarningKind.RecoveryUnnamed, "cli.plan.warning.recoveryUnnamed")] public void Each_warning_has_its_own_sentence_in_both_numbers(PlanWarningKind kind, string key) { + // The fourth argument is the bare name the restart sentence quotes in its command since backlog 501 - + // the other two sentences have no fourth place and ignore it. Assert.Equal( - Texts.Of($"{key}.one", "Spooler", 1, "Spooler"), + Texts.Of($"{key}.one", "Spooler", 1, "Spooler", "Spooler"), PlanText.Describe(new PlanWarning(kind, "Spooler", ["Spooler"]))); Assert.Equal( diff --git a/tests/Bws.Cli.Tests/ComingBackTextTests.cs b/tests/Bws.Cli.Tests/ComingBackTextTests.cs new file mode 100644 index 0000000..712e2a3 --- /dev/null +++ b/tests/Bws.Cli.Tests/ComingBackTextTests.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using Bws.Core; +using Bws.Core.Planning; + +namespace Bws.Cli.Tests; + +/// +/// When Windows starts an ended entry again, in the terminal - backlog 501, the owner's decision of 2026-09-30: +/// the restart warning says it beside every name, and a run that ended the process says who comes back. +/// +/// Quoted as English here, on purpose, in two places. The shape "Spooler (60 s or 120 s later)" is the +/// whole of the change as a person sees it, and a test holding only the keys would pass over a list joined +/// wrongly. The rest holds the keys. +/// +public sealed class ComingBackTextTests +{ + private static readonly TimeSpan Minute = TimeSpan.FromSeconds(60); + + [Fact] + public void The_restart_warning_says_when_beside_the_name() + { + var said = PlanText.Describe(Warned(new RecoveryRestart("Spooler", [Minute, TimeSpan.FromSeconds(120)]))); + + Assert.Contains("Windows starts Spooler (60 s or 120 s later) again by itself", said, StringComparison.Ordinal); + Assert.EndsWith("sc.exe qfailure Spooler", said, StringComparison.Ordinal); + } + + [Fact] + public void Every_delay_is_named_and_a_fraction_of_a_second_stays_in_seconds() + { + var said = PlanText.Describe(Warned( + new RecoveryRestart("WpnService", [.. new[] { 1, 2, 4, 8, 16 }.Select(seconds => TimeSpan.FromSeconds(seconds))]), + new RecoveryRestart("Fax", [TimeSpan.FromMilliseconds(100)]))); + + Assert.Contains(": WpnService (1 s, 2 s, 4 s, 8 s or 16 s later), Fax (0.1 s later).", said, StringComparison.Ordinal); + } + + [Fact] + public void A_run_that_ended_the_process_says_who_comes_back_before_the_warnings() + { + var report = PlanText.Render(Ran(StepOutcome.Succeeded)); + + var line = Texts.Of( + "cli.run.comesBack.one", + "Spooler", + Texts.Of("cli.plan.recovery.later", "Spooler", Texts.Of("cli.run.took.seconds", "60"))); + + Assert.Contains(line, report, StringComparison.Ordinal); + Assert.True( + report.IndexOf(line, StringComparison.Ordinal) < report.IndexOf(Texts.Of("cli.plan.warnings"), StringComparison.Ordinal)); + } + + [Fact] + public void A_run_that_never_ended_the_process_says_nothing_about_coming_back() + { + var report = PlanText.Render(Ran(StepOutcome.Failed)); + + Assert.DoesNotContain("after the process behind Spooler was ended", report, StringComparison.Ordinal); + } + + [Fact] + public void The_machine_readable_warning_gains_no_field() + { + var warning = JsonDocument.Parse(PlanJson.Render(Ran(StepOutcome.Succeeded))).RootElement.GetProperty("warnings")[0]; + + Assert.Equal(["kind", "serviceName", "related", "message"], warning.EnumerateObject().Select(field => field.Name)); + Assert.Contains("Spooler (60 s later)", warning.GetProperty("message").GetString(), StringComparison.Ordinal); + } + + private static PlanWarning Warned(params RecoveryRestart[] restarts) => + new(PlanWarningKind.RecoveryRestarts, restarts[0].ServiceName, [.. restarts.Select(one => one.ServiceName)]) + { + Restarts = restarts + }; + + private static PlanRun Ran(StepOutcome outcome) + { + var ending = new PlanStep( + "Spooler", "Print Spooler", StepOperation.Terminate, StepReason.Requested, ProcessId: 4812, TakesWithIt: []); + + return new PlanRun + { + Plan = new OperationPlan + { + Action = new ServiceAction(ActionKind.ForceStop, "Spooler"), + Steps = [ending], + Warnings = [Warned(new RecoveryRestart("Spooler", [Minute]))], + Problems = [] + }, + Results = + [ + new StepResult + { + Step = ending, + Outcome = outcome, + SkippedBecause = null, + Status = outcome == StepOutcome.Succeeded ? EntryStatus.Stopped : EntryStatus.Running, + ProcessId = Reading.NotRead(), + ErrorCode = outcome == StepOutcome.Succeeded ? 0 : 5, + Error = outcome == StepOutcome.Succeeded ? null : "Access is denied.", + Milliseconds = 18 + } + ], + Cancelled = false, + Ceiling = Minute + }; + } +} diff --git a/tests/Bws.Core.Tests/AftermathTests.cs b/tests/Bws.Core.Tests/AftermathTests.cs index 5671256..61b3e79 100644 --- a/tests/Bws.Core.Tests/AftermathTests.cs +++ b/tests/Bws.Core.Tests/AftermathTests.cs @@ -32,6 +32,29 @@ public void A_restart_in_the_recovery_list_is_said_before_anything_is_ended() Assert.Equal("Netlogon", Assert.Single(Warning(plan, PlanWarningKind.RecoveryRestarts).Related)); } + [Fact] + public void The_restart_warning_carries_every_delay_once_in_the_order_of_the_list() + { + // Backlog 501. 60 s twice and 120 s once is two delays, a program is not a restart, and a neighbour + // with no restart at all is not named - the names and the delays come from one reading. + var facts = new FakeEndingFacts() + .Recovering( + "Netlogon", + new RecoveryItem(RecoveryAction.RestartService, TimeSpan.FromSeconds(60)), + new RecoveryItem(RecoveryAction.RunProgram, TimeSpan.FromSeconds(5)), + new RecoveryItem(RecoveryAction.RestartService, TimeSpan.FromSeconds(120)), + new RecoveryItem(RecoveryAction.RestartService, TimeSpan.FromSeconds(60)), + new RecoveryItem(RecoveryAction.Nothing, TimeSpan.Zero)) + .Recovering("SessionEnv", new RecoveryItem(RecoveryAction.Nothing, TimeSpan.FromSeconds(30))); + + var warning = Warning(Forced(Sharing(), facts), PlanWarningKind.RecoveryRestarts); + + var restart = Assert.Single(warning.Restarts); + Assert.Equal("Netlogon", restart.ServiceName); + Assert.Equal([TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(120)], restart.After); + Assert.Equal(["Netlogon"], warning.Related); + } + [Fact] public void A_neighbour_that_sets_a_program_off_is_named_and_the_entry_is_asked_first() { diff --git a/tests/Bws.Core.Tests/ComingBackTests.cs b/tests/Bws.Core.Tests/ComingBackTests.cs new file mode 100644 index 0000000..8a33b9f --- /dev/null +++ b/tests/Bws.Core.Tests/ComingBackTests.cs @@ -0,0 +1,210 @@ +using Bws.Core.Planning; +using Bws.Core.Tests.Fakes; + +namespace Bws.Core.Tests; + +/// +/// Who Windows starts again after a run that ended a process, and when - backlog 501, the owner's decision +/// of 2026-09-30. +/// +/// Measured the day before, on the throwaway machine: a forced stop with a restart after 3000 ms +/// reported the step succeeded, the run complete and exit code 0, and three seconds later the service was +/// running. These pin who the line after such a run names. The rule underneath is Microsoft's own - a +/// service fails when its process ends without it reporting Stopped - so what each test varies is whether +/// an entry reported Stopped before the ending, and whether the run started it again afterwards. +/// +public sealed class ComingBackTests +{ + private const int Held = 4812; + + private static readonly RecoveryRestart SpoolerBack = + new("Spooler", [TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(120)]); + + private static readonly RecoveryRestart HousemateBack = new("Housemate", [TimeSpan.Zero]); + + [Fact] + public void A_forced_stop_names_everybody_who_died_with_the_process_and_when() + { + var control = new FakeScmControl().At("Spooler", EntryStatus.Running).At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced(ActionKind.ForceStop, Ending() with { Reason = StepReason.Requested })); + + Assert.Equal(Held, Assert.Single(control.Ended)); + Assert.Equal([SpoolerBack, HousemateBack], run.ComingBack); + } + + [Fact] + public void A_neighbour_whose_own_stop_arrived_sets_nothing_off() + { + var control = new FakeScmControl() + .Reaching("Spooler", Stopping()) + .At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced( + ActionKind.ForceStop, + Step("Spooler", StepOperation.Stop, StepReason.Requested), + Step("Housemate", StepOperation.Stop, StepReason.SharesTheProcess), + Ending())); + + Assert.Equal(StepOutcome.Succeeded, run.Results[1].Outcome); + Assert.Equal(["Spooler"], Names(run)); + } + + [Fact] + public void A_neighbour_whose_own_stop_timed_out_dies_with_the_process() + { + // Still in StopPending when the process went, so it never reported Stopped - the one case where the + // way back and this line disagree, and why this is not NetEffect's count of who the ending took. + var control = new FakeScmControl() + .Reaching("Spooler", Stopping()) + .Reaching("Housemate", Stopping()); + + var run = Run(control, Forced( + ActionKind.ForceStop, + Step("Spooler", StepOperation.Stop, StepReason.Requested), + Step("Housemate", StepOperation.Stop, StepReason.SharesTheProcess), + Ending())); + + Assert.Equal(StepOutcome.TimedOut, run.Results[1].Outcome); + Assert.Equal(["Spooler", "Housemate"], Names(run)); + } + + [Fact] + public void An_entry_Windows_started_again_at_once_is_left_to_its_own_line() + { + var control = new FakeScmControl() + .ComingBack("Spooler", new ServiceProgress(EntryStatus.Running, 0, TimeSpan.Zero, 5555)) + .At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced(ActionKind.ForceStop, Ending() with { Reason = StepReason.Requested })); + + Assert.True(run.Results[0].StartedAgain); + Assert.Equal(["Housemate"], Names(run)); + } + + [Fact] + public void A_forced_restart_that_put_everything_back_announces_nothing() + { + var control = new FakeScmControl().At("Spooler", EntryStatus.Running).At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced( + ActionKind.ForceRestart, + Ending() with { Reason = StepReason.Requested }, + Step("Spooler", StepOperation.Start, StepReason.Restore), + Step("Housemate", StepOperation.Start, StepReason.Restore))); + + Assert.True(run.Completed); + Assert.Empty(run.ComingBack); + } + + [Fact] + public void An_entry_whose_start_back_was_refused_is_left_to_its_recovery_list() + { + var control = new FakeScmControl() + .At("Spooler", EntryStatus.Running) + .RefusingRequests("Spooler", 1058) + .At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced( + ActionKind.ForceRestart, + Ending() with { Reason = StepReason.Requested }, + Step("Spooler", StepOperation.Start, StepReason.Restore), + Step("Housemate", StepOperation.Start, StepReason.Restore))); + + Assert.Equal(StepOutcome.Failed, run.Results[1].Outcome); + Assert.Equal(["Spooler"], Names(run)); + } + + [Fact] + public void A_run_left_undone_after_the_ending_names_what_it_did_not_start() + { + // Leave the rest undone, pressed while the process was being ended: the steps putting things back are + // never tried, so both entries are left to their lists - the case this line matters most in. + var plan = Forced( + ActionKind.ForceRestart, + Ending() with { Reason = StepReason.Requested }, + Step("Spooler", StepOperation.Start, StepReason.Restore)); + + var run = Carried(plan, [Came(plan.Steps[0], StepOutcome.Succeeded), Came(plan.Steps[1], StepOutcome.Skipped)]); + + Assert.Equal(["Spooler", "Housemate"], Names(run)); + } + + [Fact] + public void Nothing_comes_back_when_the_process_was_never_ended() + { + using var interruption = new CancellationTokenSource(); + interruption.Cancel(); + + var control = new FakeScmControl().At("Spooler", EntryStatus.Running).At("Housemate", EntryStatus.Running); + + var run = new PlanRunner(control, new FakeClock()).Run( + Forced(ActionKind.ForceStop, Ending() with { Reason = StepReason.Requested }), + TimeSpan.FromSeconds(30), + interruption.Token); + + Assert.Empty(control.Ended); + Assert.Empty(run.ComingBack); + } + + [Fact] + public void A_plan_that_warned_about_no_restart_announces_none() + { + var control = new FakeScmControl().At("Spooler", EntryStatus.Running).At("Housemate", EntryStatus.Running); + + var run = Run(control, Forced(ActionKind.ForceStop, Ending() with { Reason = StepReason.Requested }) with + { + Warnings = [] + }); + + Assert.Equal(Held, Assert.Single(control.Ended)); + Assert.Empty(run.ComingBack); + } + + private static ServiceProgress Stopping() => new(EntryStatus.StopPending, 0, TimeSpan.Zero, Held); + + private static PlanStep Step(string name, StepOperation operation, StepReason reason) => + new(name, name, operation, reason); + + private static PlanStep Ending() => + new("Spooler", "Spooler", StepOperation.Terminate, StepReason.Escalation, ProcessId: Held, TakesWithIt: ["Housemate"]); + + private static OperationPlan Forced(ActionKind kind, params PlanStep[] steps) => new() + { + Action = new ServiceAction(kind, "Spooler"), + Steps = steps, + Warnings = + [ + new PlanWarning(PlanWarningKind.RecoveryRestarts, "Spooler", ["Spooler", "Housemate"]) + { + Restarts = [SpoolerBack, HousemateBack] + } + ], + Problems = [] + }; + + private static PlanRun Run(FakeScmControl control, OperationPlan plan) => + new PlanRunner(control, new FakeClock()).Run(plan, TimeSpan.FromSeconds(30)); + + private static PlanRun Carried(OperationPlan plan, IReadOnlyList results) => new() + { + Plan = plan, + Results = results, + Cancelled = true, + Ceiling = TimeSpan.FromSeconds(30) + }; + + private static StepResult Came(PlanStep step, StepOutcome outcome) => new() + { + Step = step, + Outcome = outcome, + SkippedBecause = outcome == StepOutcome.Skipped ? SkipReason.Cancelled : null, + Status = EntryStatus.Stopped, + ProcessId = Reading.NotRead(), + ErrorCode = 0, + Error = null, + Milliseconds = 0 + }; + + private static IEnumerable Names(PlanRun run) => run.ComingBack.Select(one => one.ServiceName); +} diff --git a/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs b/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs index 56b7d52..60ec6a7 100644 --- a/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs +++ b/tests/Bws.Core.Tests/Fakes/FakeEndingFacts.cs @@ -26,7 +26,7 @@ internal sealed class FakeEndingFacts : IEndingFactsReader private readonly Dictionary> _rights = []; private readonly Dictionary> _created = []; private readonly Dictionary> _critical = []; - private readonly Dictionary>> _recovery = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary>> _recovery = new(StringComparer.OrdinalIgnoreCase); /// Every entry whose recovery list was asked for, in order. internal List AskedRecovery { get; } = []; @@ -45,17 +45,21 @@ internal FakeEndingFacts CriticalUnreadable(int processId, int errorCode = 5) return this; } - /// What the manager does to this entry when its process dies, item by item. - internal FakeEndingFacts Recovering(string serviceName, params RecoveryAction[] actions) + /// What the manager does to this entry when its process dies, item by item, each at once. + internal FakeEndingFacts Recovering(string serviceName, params RecoveryAction[] actions) => + Recovering(serviceName, [.. actions.Select(action => new RecoveryItem(action, TimeSpan.Zero))]); + + /// The same with the delay of each item, for the sentences that say when (backlog 501). + internal FakeEndingFacts Recovering(string serviceName, params RecoveryItem[] items) { - _recovery[serviceName] = Reading>.Present(actions); + _recovery[serviceName] = Reading>.Present(items); 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."); + _recovery[serviceName] = Reading>.Denied(errorCode, "Access is denied."); return this; } @@ -65,18 +69,18 @@ internal FakeEndingFacts RefusingRecovery(string serviceName, int errorCode = 5) /// internal FakeEndingFacts GoneFromTheManager(string serviceName) { - _recovery[serviceName] = Reading>.Absent(); + _recovery[serviceName] = Reading>.Absent(); return this; } - public Reading> ReadRecovery(string serviceName) + 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([]); + : Reading>.Present([]); } /// Every process this was asked about, in order, so a test can see it was asked once. diff --git a/tests/Bws.Gui.Tests/ComingBackWordsGuards.cs b/tests/Bws.Gui.Tests/ComingBackWordsGuards.cs new file mode 100644 index 0000000..6278bdb --- /dev/null +++ b/tests/Bws.Gui.Tests/ComingBackWordsGuards.cs @@ -0,0 +1,118 @@ +using Bws.Core; +using Bws.Core.Planning; +using Bws.Gui.ViewModels; + +namespace Bws.Gui.Tests; + +/// +/// When Windows starts an ended entry again, in the window - backlog 501, the owner's decision of 2026-09-30: +/// the restart warning says it beside every name, and the notice after a run that ended the process says who +/// comes back. +/// +/// The notice rather than a section of its own, because the second sentence qualifies the first - +/// "Done. The entry is where you asked." is true when it is read and stops being true a minute later. +/// +public sealed class ComingBackWordsGuards +{ + private static readonly TimeSpan Minute = TimeSpan.FromSeconds(60); + + [Fact] + public void The_restart_warning_says_when_beside_every_name() + { + var said = PlanWords.Describe(Warned( + new RecoveryRestart("WpnService", [.. new[] { 1, 2, 4, 8, 16 }.Select(seconds => TimeSpan.FromSeconds(seconds))]), + new RecoveryRestart("Fax", [TimeSpan.FromMilliseconds(100)]))); + + Assert.Contains(": WpnService (1 s, 2 s, 4 s, 8 s or 16 s later), Fax (0.1 s later).", said, StringComparison.Ordinal); + } + + [Fact] + public void The_notice_after_a_run_that_ended_the_process_says_who_comes_back() + { + var panel = new Planned { Elevated = true }; + + panel.Show(Forcing()); + panel.Finished(Ran(panel, StepOutcome.Succeeded)); + + Assert.Equal( + Texts.Of( + "gui.plan.notice.andAfter", + Texts.Of("gui.plan.notice.done.one"), + Texts.Of( + "gui.plan.notice.comesBack.one", + "Spooler", + Texts.Of("gui.plan.recovery.later", "Spooler", Texts.Of("gui.plan.recovery.seconds", "60")))), + panel.Notice); + } + + [Fact] + public void The_notice_after_a_run_that_never_ended_the_process_is_only_the_report() + { + var panel = new Planned { Elevated = true }; + + panel.Show(Forcing()); + panel.Finished(Ran(panel, StepOutcome.Failed)); + + Assert.Equal(Texts.Of("gui.plan.notice.partly.one"), panel.Notice); + } + + private static PlanWarning Warned(params RecoveryRestart[] restarts) => + new(PlanWarningKind.RecoveryRestarts, restarts[0].ServiceName, [.. restarts.Select(one => one.ServiceName)]) + { + Restarts = restarts + }; + + private static BulkPlan Forcing() => new() + { + Action = new BulkAction(ActionKind.ForceStop, ["Spooler"]), + Plans = + [ + new OperationPlan + { + Action = new ServiceAction(ActionKind.ForceStop, "Spooler"), + Steps = + [ + new PlanStep( + "Spooler", "Print Spooler", StepOperation.Terminate, StepReason.Requested, ProcessId: 4812, TakesWithIt: []) + ], + Warnings = [Warned(new RecoveryRestart("Spooler", [Minute]))], + Problems = [] + } + ], + Problems = [] + }; + + private static BulkRun Ran(Planned panel, StepOutcome outcome) + { + var plan = panel.Plan!; + var one = plan.Plans[0]; + + return new BulkRun + { + Plan = plan, + Runs = + [ + new PlanRun + { + Plan = one, + Results = + [ + new StepResult + { + Step = one.Steps[0], + Outcome = outcome, + SkippedBecause = null, + Status = outcome == StepOutcome.Succeeded ? EntryStatus.Stopped : EntryStatus.Running, + ProcessId = Reading.NotRead(), + ErrorCode = outcome == StepOutcome.Succeeded ? 0 : 5, + Error = outcome == StepOutcome.Succeeded ? null : "Access is denied.", + Milliseconds = 18 + } + ], + Cancelled = false, + Ceiling = Minute + } + ] + }; + } +} diff --git a/tests/Bws.Integration.Tests/RecoveryContractTests.cs b/tests/Bws.Integration.Tests/RecoveryContractTests.cs index c93c8ec..4d80b10 100644 --- a/tests/Bws.Integration.Tests/RecoveryContractTests.cs +++ b/tests/Bws.Integration.Tests/RecoveryContractTests.cs @@ -26,30 +26,43 @@ public void The_recovery_list_of_RpcSs_reads_as_sc_exe_prints_it() 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. + // its line with the FAILURE_ACTIONS label and a colon. The delay is compared as well since + // 2026-09-30 (backlog 501), when the plan started saying it - the number is the first run of digits + // after the equals sign, in milliseconds, whatever language the unit word is printed in. 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()) + .Select(line => Kind(line) + " " + Delay(line)) .ToArray(); string[] spoken = [ .. ours.Value! - .Where(action => action is not (RecoveryAction.Nothing or RecoveryAction.Unnamed)) - .Select(action => action switch + .Where(item => item.Action is not (RecoveryAction.Nothing or RecoveryAction.Unnamed)) + .Select(item => (item.Action switch { RecoveryAction.RestartService => "RESTART", RecoveryAction.RunProgram => "RUN PROCESS", _ => "REBOOT" - }) + }) + " " + (long)item.Delay.TotalMilliseconds) ]; Assert.NotEmpty(theirs); Assert.Equal(theirs, spoken); } + private static string Kind(string line) + { + var before = line[..line.IndexOf("--", StringComparison.Ordinal)]; + return before[(before.LastIndexOf(':') + 1)..].Trim(); + } + + private static string Delay(string line) + { + var after = line[(line.IndexOf('=', StringComparison.Ordinal) + 1)..].TrimStart(); + return new string([.. after.TakeWhile(char.IsAsciiDigit)]); + } + [Fact] public void A_service_that_is_not_there_has_no_recovery_rather_than_a_refused_one() {