Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 63 additions & 2 deletions src/Bws.Cli/PlanText.Aftermath.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Globalization;
using System.Text;
using Bws.Core.Planning;

namespace Bws.Cli;

/// <summary>
/// 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.
///
/// <b>Its own file, reached from the discard arm of the two switches beside it</b>, so that neither grows:
/// the window's twin of the warning switch stands one fork under the complexity ceiling, and this side is
Expand All @@ -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),
Expand Down Expand Up @@ -56,4 +64,57 @@ internal static partial class PlanText
_ => throw new ArgumentOutOfRangeException(
nameof(problem), problem.Kind, EquivalentCommand.Unhandled)
};

/// <summary>
/// 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: <see cref="PlanRun.ComingBack"/> decides who.
///
/// <b>Its own paragraph under the steps</b>, 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.
/// </summary>
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))])));
}

/// <summary>
/// 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.
/// </summary>
private static string Later(string serviceName, IReadOnlyList<RecoveryRestart> 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;

/// <summary>
/// "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 (<see cref="RecoveryRestart"/>).
/// </summary>
private static string Delays(IReadOnlyList<TimeSpan> 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]));

/// <summary>
/// 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.
/// </summary>
private static string Seconds(TimeSpan delay) =>
Texts.Of("cli.run.took.seconds", delay.TotalSeconds.ToString("0.#", CultureInfo.InvariantCulture));
}
2 changes: 2 additions & 0 deletions src/Bws.Cli/PlanText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ private static string Render(
Took((long)run!.Ceiling.TotalMilliseconds)));
}

AddComingBack(text, run);

if (plan.Warnings.Count > 0)
{
text.AppendLine();
Expand Down
6 changes: 5 additions & 1 deletion src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand All @@ -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}",
Expand Down
31 changes: 25 additions & 6 deletions src/Bws.Core/EndingFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,32 @@ public static EndingFacts NobodyAsked() =>
}

/// <summary>
/// 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.
///
/// <b>The delay is kept since 2026-09-30, backlog 501, and until that day it was read and dropped.</b>
/// 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.
/// </summary>
/// <param name="Action">What this item does.</param>
/// <param name="Delay">
/// How long the manager waits after the failure before doing it - Microsoft's <c>SC_ACTION.Delay</c>, in
/// milliseconds there. Zero is an ordinary value: five services on that machine restart at once.
/// </param>
public readonly record struct RecoveryItem(RecoveryAction Action, TimeSpan Delay);

/// <summary>
/// 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, <see cref="RecoveryItem"/> carries it with its delay.
///
/// <b>Read for the plan that ends a process and for nothing else, since 2026-09-30</b> (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.
///
/// <b>Which item of the list runs is not knowable from outside.</b> The manager counts failures since
/// the machine started and runs item N for failure N, repeating the last - and no call hands out the
Expand Down Expand Up @@ -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.
/// </summary>
Reading<IReadOnlyList<RecoveryAction>> ReadRecovery(string serviceName);
Reading<IReadOnlyList<RecoveryItem>> ReadRecovery(string serviceName);
}

/// <summary>
Expand All @@ -166,6 +185,6 @@ internal sealed class NobodyToAsk : IEndingFactsReader

public EndingFacts Read(int processId) => EndingFacts.NobodyAsked();

public Reading<IReadOnlyList<RecoveryAction>> ReadRecovery(string serviceName) =>
Reading<IReadOnlyList<RecoveryAction>>.NotRead();
public Reading<IReadOnlyList<RecoveryItem>> ReadRecovery(string serviceName) =>
Reading<IReadOnlyList<RecoveryItem>>.NotRead();
}
106 changes: 103 additions & 3 deletions src/Bws.Core/Planning/Aftermath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace Bws.Core.Planning;
internal static class Aftermath
{
/// <summary>One entry that dies with the process, and its recovery list as the manager answered.</summary>
internal readonly record struct Recovered(string ServiceName, Reading<IReadOnlyList<RecoveryAction>> Actions);
internal readonly record struct Recovered(string ServiceName, Reading<IReadOnlyList<RecoveryItem>> Actions);

/// <summary>
/// The last question before a plan that ends a process exists - what the manager does to the dead once
Expand Down Expand Up @@ -94,14 +94,114 @@ .. sharing.Prepend(target)
/// <summary>
/// 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.
///
/// <b>The first carries WHEN since 2026-09-30</b> (backlog 501) - <see cref="PlanWarning.Restarts"/>, the
/// names in <see cref="PlanWarning.Related"/> taken from the same list in the same order.
/// </summary>
internal static void AddWarnings(List<PlanWarning> warnings, ScmEntry target, IReadOnlyList<Recovered> recovery)
{
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);
}

/// <summary>
/// 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.
/// </summary>
private static List<RecoveryRestart> Restarts(IReadOnlyList<Recovered> 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)
];

/// <summary>
/// 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.
///
/// <b>An entry comes back when it died with the process and this run did not start it afterwards.</b>
/// Died with it: Microsoft counts a service as failed when its process ends WITHOUT it reporting Stopped
/// (<c>SERVICE_FAILURE_ACTIONSW</c>, 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.
///
/// <b>NOT <see cref="NetEffect"/>'s count of who the ending took down, and that was checked on a second
/// case before this was written.</b> 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.
///
/// <b>What the list said when the plan was built is what this repeats</b> - 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.
/// </summary>
internal static IReadOnlyList<RecoveryRestart> ComingBack(OperationPlan plan, IReadOnlyList<StepResult> 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)))
];
}

/// <summary>
/// Where the step that ended the process is, when it ended it - arrived, timed out watching the entry
/// after the call worked, or answered by Windows starting the entry again at once.
/// </summary>
private static int? EndedAt(IReadOnlyList<StepResult> results)
{
for (var index = 0; index < results.Count; index++)
{
if (results[index] is { Step.Operation: StepOperation.Terminate } result
&& (result.Outcome is StepOutcome.Succeeded or StepOutcome.TimedOut || result.StartedAgain))
{
return index;
}
}

return null;
}

private static bool DiedWith(string serviceName, StepResult ending, IEnumerable<StepResult> 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<StepResult> 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<PlanWarning> warnings, ScmEntry target, List<string> named, PlanWarningKind kind)
{
if (named.Count > 0)
Expand All @@ -115,5 +215,5 @@ private static void Warn(List<PlanWarning> warnings, ScmEntry target, List<strin
/// machine started, and no call hands out N. Absent and unread lists hold nothing.
/// </summary>
private static List<string> Having(IReadOnlyList<Recovered> 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)];
}
Loading
Loading