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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ is not part of this repository.
- 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.
- Stop pressed before the first step of a restart, or Ctrl+C, no longer starts a service that was
not running. A run now starts again only what it stopped itself: after an interruption, after a
stop that was refused, and for a dependent somebody else stopped between the preview and the
run, the step says "not started, this run never stopped it" and `skippedBecause` in the JSON of
a run is `nothingToPutBack`. The same goes for a restart of a whole selection interrupted half
way. A service stopped by somebody else after the preview of its own restart is left stopped,
and the run says it did not end where the plan wanted it.
- Restarting a service that is not running only starts it, from the window and with
`bws restart`, and the preview says so - one step and the warning "is not running, so
restarting it only starts it" (`restartOnlyStarts` in the JSON). Until now the preview showed a
stop and a start. A disabled service that is not running is no longer refused with a sentence
about stopping it - the plan warns that Windows will refuse to start it, the same as a plan to
start it.

## [0.3.0] - 2026-09-25

Expand Down
33 changes: 33 additions & 0 deletions src/Bws.Cli/PlanText.Starting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Bws.Core.Planning;

namespace Bws.Cli;

/// <summary>
/// What a plan says about the start in it - a start the manager is known to refuse, and a restart that is
/// only a start because the entry is not running.
///
/// <b>Its own file since 2026-09-30, reached from the discard arm of the warning switch beside it</b>
/// (stability report W-5, package D). The window's twin of that switch stood one fork under the complexity
/// ceiling, and the third warning about a start would have taken it there - so the two that were already
/// in it moved here with the new one, and both switches grew thinner rather than one of them fatter. The
/// terminal is cut the same way so the two keep one shape. What this switch does not know goes on to
/// <see cref="Aftermath(PlanWarning)"/>, which keeps the refusal every kind without a sentence meets.
/// </summary>
internal static partial class PlanText
{
private static string Starting(PlanWarning warning) => warning.Kind switch
{
// The line that makes the start possible, built the way the offer to stop a disabled entry builds
// its own - the window has a startup type action for this and a terminal has the command.
PlanWarningKind.DisabledCannotStart => Texts.Of(
"cli.plan.warning.disabledCannotStart", warning.ServiceName,
EquivalentCommand.For(new ServiceAction(
ActionKind.SetStartType, warning.ServiceName, To: StartSetting.Manual))),

PlanWarningKind.PausedCannotStart => Texts.Of("cli.plan.warning.pausedCannotStart", warning.ServiceName),

PlanWarningKind.RestartOnlyStarts => Texts.Of("cli.plan.warning.restartOnlyStarts", warning.ServiceName),

_ => Aftermath(warning)
};
}
14 changes: 3 additions & 11 deletions src/Bws.Cli/PlanText.Warnings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,21 +77,13 @@ internal static partial class PlanText

PlanWarningKind.StartsAtNextBoot => Texts.Of("cli.plan.warning.startsAtNextBoot", warning.ServiceName),

// The line that makes the start possible, built the way the offer above builds its own - the
// window has a startup type action for this and a terminal has the command.
PlanWarningKind.DisabledCannotStart => Texts.Of(
"cli.plan.warning.disabledCannotStart", warning.ServiceName,
EquivalentCommand.For(new ServiceAction(
ActionKind.SetStartType, warning.ServiceName, To: StartSetting.Manual))),

PlanWarningKind.PausedCannotStart => Texts.Of("cli.plan.warning.pausedCannotStart", warning.ServiceName),

// NAMED ARMS AND A REFUSAL, SINCE 2026-09-06, AND THE WILDCARD THAT WAS HERE IS WHY. Every
// 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. Since 2026-09-30 the refusal
// stands at the end of the next switch along, which names what an ending sets off.
_ => Aftermath(warning)
// stands at the end of a chain of switches along - the start in the plan first, then what an
// ending sets off.
_ => Starting(warning)
};
}
2 changes: 2 additions & 0 deletions src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"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.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.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}",
Expand All @@ -115,6 +116,7 @@
"cli.run.outcome.earlierStepFailed": "not tried, an earlier step did not work",
"cli.run.outcome.cancelled": "not tried, the run was interrupted",
"cli.run.outcome.processStays": "not needed, the process it lives in is not being ended",
"cli.run.outcome.nothingToPutBack": "not started, this run never stopped it",

"cli.run.took.milliseconds": "{0} ms",
"cli.run.took.seconds": "{0} s",
Expand Down
30 changes: 28 additions & 2 deletions src/Bws.Core/Planning/NetEffect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,26 @@ public static IReadOnlyList<ReversalStep> Of(IEnumerable<StepResult> results)
.Select(one => one.Step)];
}

/// <summary>
/// Whether these results took the entry down - asked by a step that puts the entry back, before it is
/// tried.
///
/// <b>HERE RATHER THAN IN THE RUNNER BECAUSE IT IS THE WAY BACK'S OWN QUESTION, and two answers to it
/// would be two things that have to agree</b> - the argument this file was made for. On the owner's
/// decision of 2026-09-30 (stability report W-5) a step putting something back gives back only what the
/// run took: a stop that arrived or timed out, or an ending that took the entry with its process. Until
/// then every such step ran, and an entry that was stopped all along was started by a restart nobody
/// let begin.
///
/// <b>One case counts here and not in the way back:</b> an ending after which Windows started the entry
/// again at once (<see cref="StepResult.StartedAgain"/>). The process was ended, so the entry WAS taken
/// down - the step putting it back reads it, finds it running and says so. For the way back the same
/// entry ended where it began, which is why <see cref="Of"/> does not count it.
/// </summary>
internal static bool TookDown(IEnumerable<StepResult> results, string serviceName) =>
Tally(results, endingCounts: true).Moves.TryGetValue(serviceName, out var moved)
&& moved.Last is StepOperation.Stop or StepOperation.Terminate;

/// <summary>
/// Where each entry was first and last moved, and what each startup setting was before and after -
/// the two tallies <see cref="Of"/> turns into lines.
Expand All @@ -131,10 +151,14 @@ public static IReadOnlyList<ReversalStep> Of(IEnumerable<StepResult> results)
/// (stability report W-10) and Of went past the length the shape guard calls close to its ceiling.
/// The seam is the one the method already had: counting what happened, then saying what undoes it.
/// </summary>
/// <param name="endingCounts">
/// Count an ending that Windows answered by starting the entry again at once - <see cref="TookDown"/>
/// asks with it, <see cref="Of"/> without.
/// </param>
private static (
Dictionary<string, (StepOperation First, StepOperation Last, int When)> Moves,
Dictionary<string, (StartSetting? From, StartSetting? To, int When)> Settings)
Tally(IEnumerable<StepResult> results)
Tally(IEnumerable<StepResult> results, bool endingCounts = false)
{
var moves = new Dictionary<string, (StepOperation First, StepOperation Last, int When)>(
StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -166,7 +190,9 @@ private static (
foundStopped.Add(result.Step.ServiceName);
}

if (result.Outcome != StepOutcome.Succeeded && result.Outcome != StepOutcome.TimedOut)
if (result.Outcome != StepOutcome.Succeeded
&& result.Outcome != StepOutcome.TimedOut
&& !(endingCounts && result.StartedAgain))
{
continue;
}
Expand Down
52 changes: 45 additions & 7 deletions src/Bws.Core/Planning/PlanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public OperationPlan Build(ServiceAction action)
return refused;
}

// What is worked out below, which is not always what was asked for - AsPlanned says when. The
// plan still carries the ask itself, so the command it hands back is the one somebody typed.
var asked = AsPlanned(action, target);
var warnings = new List<PlanWarning>();
var steps = new List<PlanStep>();

Expand All @@ -60,15 +63,15 @@ public OperationPlan Build(ServiceAction action)
// down - setting a start type does not move the service at all, so the question does not
// arise. Both end up with an empty list and they get there for different reasons. A setting
// carrying a stop is the exception, and it asks exactly what a plain stop asks.
var blocking = action.Kind is ActionKind.Start
|| (action.Kind == ActionKind.SetStartType && !StopsAlong(action, target))
var blocking = asked.Kind is ActionKind.Start
|| (asked.Kind == ActionKind.SetStartType && !StopsAlong(asked, target))
? []
: StoppingOrder(target, warnings);

// Asking to stop one service is not asking to stop seven. Without the word, the
// ones in the way are named and left alone, and the plan says plainly that the
// manager will refuse the stop while they run.
var cascade = action.IncludeDependents ? blocking : [];
var cascade = asked.IncludeDependents ? blocking : [];

// Found by looking at a real plan rather than by reasoning: stopping BFE on this
// machine drags in WdNisDrv and wtd, both kernel drivers. Refusing a driver as the
Expand Down Expand Up @@ -103,22 +106,28 @@ public OperationPlan Build(ServiceAction action)
ending = decided;
}

if (StuckDown(action.Kind, target, [.. cascade, .. ending?.Sharing ?? []]) is { Count: > 0 } cannotComeBack)
if (StuckDown(asked.Kind, target, [.. cascade, .. ending?.Sharing ?? []]) is { Count: > 0 } cannotComeBack)
{
return Refuse(action, PlanProblemKind.CannotComeBack, cannotComeBack);
}

if (!action.IncludeDependents && blocking.Count > 0)
if (!asked.IncludeDependents && blocking.Count > 0)
{
warnings.Add(new PlanWarning(
PlanWarningKind.DependentsInTheWay,
target.ServiceName,
[.. blocking.Select(entry => entry.ServiceName)]));
}

AddSteps(steps, action, target, cascade, ending);
AddSteps(steps, asked, target, cascade, ending);

AddWarnings(warnings, target, action, cascade, ending);
AddWarnings(warnings, target, asked, cascade, ending);

if (asked != action)
{
// First, because it says what the whole plan is - every other sentence is about a step in it.
warnings.Insert(0, new PlanWarning(PlanWarningKind.RestartOnlyStarts, target.ServiceName));
}

return new OperationPlan
{
Expand Down Expand Up @@ -258,6 +267,10 @@ private static void AddSteps(
// machine on 2026-08-01 by pressing Ctrl+C during a restart, which left the
// service stopped - the plan had taken it down and then classified putting
// it back as forward progress to be abandoned.
//
// An entry read as stopped no longer reaches this arm since 2026-09-30 - it is
// planned as a start (AsPlanned) - and the runner gives back only what the run
// took down, so a restore after an interrupted or refused stop starts nothing.
AddStops(steps, cascade, target);
steps.Add(PlanSteps.Made(target, StepOperation.Start, StepReason.Restore));

Expand Down Expand Up @@ -315,6 +328,31 @@ kind is ActionKind.Restart or ActionKind.ForceRestart
.Select(entry => entry.ServiceName)]
: [];

/// <summary>
/// The ask as it is worked out: a restart of an entry that is not running becomes a start, and every
/// other ask stays what it was.
///
/// <b>The owner's decision of 2026-09-30</b> (stability report W-5), and it is the answer
/// <c>Restart-Service</c> gives - Microsoft's page for the cmdlet says a service already stopped is
/// started. Until then the plan was a stop the runner would find already done and a start that put the
/// entry back, and putting back is carried out even after an interruption - so Stop pressed before the
/// first step started a service that had been stopped all along. Planned as a start, it is a step
/// forward like any other, and Stop pressed before it starts nothing.
///
/// <b>No dependants come down.</b> They stand in the way of stopping an entry, and this one is not
/// going to be stopped. The rules of a start apply whole: a disabled entry gets the warning a start of
/// it gets, where the restart used to refuse with a sentence about stopping it - false of an entry that
/// is not running.
///
/// <b>Only Stopped as read for the preview.</b> An entry on its way down is still stopped by the plan,
/// the step waiting for it, and one whose state could not be read is read again by the runner before
/// anything is asked.
/// </summary>
private static ServiceAction AsPlanned(ServiceAction action, ScmEntry target) =>
action.Kind == ActionKind.Restart && target.Status == EntryStatus.Stopped
? action with { Kind = ActionKind.Start }
: action;

/// <summary>
/// Whether a startup setting carries a stop of its own entry - somebody took the offer, and the
/// entry is not already stopped. An entry whose state could not be read gets the step, because
Expand Down
18 changes: 16 additions & 2 deletions src/Bws.Core/Planning/PlanRun.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public enum StepOutcome
/// <summary>
/// Why a step was never attempted.
///
/// Four quite different stories, and folding them into one word would be the empty-value
/// Five quite different stories, and folding them into one word would be the empty-value
/// mistake part 3 of 06-STRUKTURA-I-KONWENCJE is about: "skipped" alone cannot tell
/// somebody whether the machine is where they wanted it or half-way to somewhere else.
/// </summary>
Expand All @@ -51,7 +51,21 @@ public enum SkipReason
/// the reason, not a failure. The neighbours are asked AFTER the entry itself since that day
/// (stability report W-2), so that a polite stop which works leaves them running.
/// </summary>
ProcessStays
ProcessStays,

/// <summary>
/// A step putting an entry back, not tried because this run never took that entry down - its stop
/// was interrupted, held back, refused, or found it already stopped.
///
/// <b>A fifth story, added on the owner's decision of 2026-09-30</b> (stability report W-5). Until
/// that day every step putting something back ran whatever had happened before it, on the belief
/// that an entry never taken down would be found already in place. That held for an entry left
/// running and failed for one that was stopped all along: pressing Stop before the first step of a
/// restart started it, and so did an interrupted bulk restart and a dependant somebody else had
/// stopped in the meantime. "Already there" would be false here - the entry may well be stopped -
/// and "interrupted" would not say why nothing was started when the run was not interrupted.
/// </summary>
NothingToPutBack
}

/// <summary>
Expand Down
Loading
Loading