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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ is not part of this repository.
- The window's reason for offering Force stop after a stop that was given up on no longer names a
number of seconds that could be wrong. The line under a step being waited for says the limit is
about progress.
- A force stop is refused when the process is one Windows marks critical, or when a service living
in it has "restart the computer" among its recovery actions - ending such a process takes the whole
machine down. It is refused as well when those recovery actions cannot be read.
- The preview of a force stop says when Windows will start a service living in the process again by
itself once the process is ended, when it will run a program named in a service's recovery
actions, and when a service has a recovery action of a kind the tool cannot name. In the JSON of a
plan these are the warnings `recoveryRestarts`, `recoveryRunsProgram` and `recoveryUnnamed`. Until
now `bws kill` reported such a service stopped while Windows was already starting it again.
- A force stop whose service Windows starts again at once is reported straight away as the process
ended and the service running again, with its new process, instead of after the whole limit as
having run out of time. In the JSON of a run that step is `"outcome": "failed"` with `errorCode` 0.
- Just before the process is ended, a force stop looks at it once more. If a service has started
inside it since the preview, or a running service outside it has started to depend on something
inside it, the process is not ended and the step says why.

## [0.3.0] - 2026-09-25

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@ line that asks for the same thing, and the way back afterwards.
names them. `--dependents` stops the first kind as part of the plan, and a dependant that will not
stop holds the process ending back. `--force` does not go with `--dependents`, because skipping the
polite step would skip theirs too.
- **Force stop says what Windows does once the process is gone.** A process Windows marks critical,
or a service whose recovery actions restart the computer, is refused - ending it takes the whole
machine down. A service Windows starts again by itself, or one whose recovery runs a program, is
named in the preview, because the stop may not last. Just before the process is ended the tool
looks at it once more, and ends nothing if a service moved into it or started depending on it
since the preview.
- **Afterwards, the way back.** A report ends with what did not work and the commands that put things
back - and the window offers *Copy all* over them.
- **A start type change moves nothing on its own.** It changes what happens at the next boot, leaves
Expand Down
59 changes: 59 additions & 0 deletions src/Bws.Cli/PlanText.Aftermath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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).
///
/// <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
/// cut the same way so the two stay one shape. The named arms and the refusal at the end are the rule
/// those switches have kept since 2026-09-06 - a kind with no sentence throws rather than borrowing one.
/// </summary>
internal static partial class PlanText
{
private static string Aftermath(PlanWarning warning) => warning.Kind switch
{
PlanWarningKind.RecoveryRestarts => Texts.Of(
Count("cli.plan.warning.recoveryRestarts", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

PlanWarningKind.RecoveryRunsProgram => Texts.Of(
Count("cli.plan.warning.recoveryRunsProgram", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

PlanWarningKind.RecoveryUnnamed => Texts.Of(
Count("cli.plan.warning.recoveryUnnamed", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

_ => throw new ArgumentOutOfRangeException(
nameof(warning), warning.Kind, EquivalentCommand.Unhandled)
};

/// <summary>
/// The three refusals. <b>An unreadable consequence with no names is the process itself</b> - whether it
/// is critical could not be read - so it gets a sentence of its own rather than an empty list.
/// </summary>
private static string Aftermath(PlanProblem problem) => problem.Kind switch
{
PlanProblemKind.ProcessIsCritical => Texts.Of("cli.plan.problem.processIsCritical", problem.ServiceName),

PlanProblemKind.RecoveryRestartsComputer => Texts.Of(
problem.Related.Count == 1
? "cli.plan.problem.recoveryRestartsComputer.one"
: "cli.plan.problem.recoveryRestartsComputer.many",
problem.ServiceName, Join(problem.Related)),

PlanProblemKind.AftermathUnreadable => Texts.Of(
problem.Related.Count == 0
? "cli.plan.problem.aftermathUnreadable.process"
: problem.Related.Count == 1
? "cli.plan.problem.aftermathUnreadable.one"
: "cli.plan.problem.aftermathUnreadable.many",
problem.ServiceName, Join(problem.Related)),

_ => throw new ArgumentOutOfRangeException(
nameof(problem), problem.Kind, EquivalentCommand.Unhandled)
};
}
6 changes: 3 additions & 3 deletions src/Bws.Cli/PlanText.Warnings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ internal static partial class PlanText
// kind but one used to fall through to "is already in that state, so nothing would change" -
// so a warning added without a sentence would not have been silent, which is survivable, but
// would have said something confident and wrong about a machine, which is not. The window's
// own switch had the same shape and was changed the same day.
_ => throw new ArgumentOutOfRangeException(
nameof(warning), warning.Kind, EquivalentCommand.Unhandled)
// own switch had the same shape and was changed the same day. Since 2026-09-30 the refusal
// stands at the end of the next switch along, which names what an ending sets off.
_ => Aftermath(warning)
};
}
10 changes: 7 additions & 3 deletions src/Bws.Cli/PlanText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ private static string Render(
// Never without words: a refusal is only ever built from a code and the system's own
// sentence for it, together. A start that fell over is not a refusal - the manager took the
// request and the SERVICE stopped - so it says that, with the service's own exit code
// (stability report W-7, 2026-09-30).
// (stability report W-7, 2026-09-30). And an ending the manager undid at once is not a refusal
// either - the process died, and the sentence says that first (W-3, the same day).
StepOutcome.Failed when result.StartedAgain => Texts.Of(
"cli.run.outcome.startedAgain", result.ProcessId.Value, Took(result.Milliseconds)),

StepOutcome.Failed => result.StoppedWhileStarting
? Texts.Of(
"cli.run.outcome.stoppedWhileStarting", result.Error!, result.ErrorCode, Took(result.Milliseconds))
Expand Down Expand Up @@ -257,8 +261,8 @@ private static string Took(long milliseconds) => milliseconds < 1000

PlanProblemKind.DependentsInTheWay or PlanProblemKind.NeighbourNeeded => StillRunning(problem),

_ => throw new ArgumentOutOfRangeException(
nameof(problem), problem.Kind, EquivalentCommand.Unhandled)
// What an ending sets off, and the refusal for a kind with no sentence, since 2026-09-30.
_ => Aftermath(problem)
};

/// <summary>
Expand Down
13 changes: 13 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,12 @@
"cli.plan.warning.startsAtNextBoot": "{0} is stopped, and a start type does not start it. It starts at the next restart of the machine.",
"cli.plan.warning.disabledCannotStart": "{0} is disabled, and Windows refuses to start a disabled entry. To make it startable first: {1}",
"cli.plan.warning.pausedCannotStart": "{0} is paused, and a start does not resume a paused service - Windows will refuse it. To resume it instead: sc.exe continue {0}",
"cli.plan.warning.recoveryRestarts.one": "Once the process behind {0} is ended, Windows starts {2} again by itself - its recovery actions say so. The stop may not last. See them with sc.exe qfailure {2}",
"cli.plan.warning.recoveryRestarts.many": "Once the process behind {0} is ended, Windows starts {1} entries again by itself - their recovery actions say so: {2}. The stop may not last. See them with sc.exe qfailure",
"cli.plan.warning.recoveryRunsProgram.one": "Once the process behind {0} is ended, Windows runs the program named in the recovery actions of {2}. See it with sc.exe qfailure {2}",
"cli.plan.warning.recoveryRunsProgram.many": "Once the process behind {0} is ended, Windows runs the programs named in the recovery actions of {1} entries: {2}. See them with sc.exe qfailure",
"cli.plan.warning.recoveryUnnamed.one": "{2} has a recovery action of a kind this tool cannot name, and Windows carries it out once the process behind {0} is ended. See it with sc.exe qfailure {2}",
"cli.plan.warning.recoveryUnnamed.many": "{1} entries have a recovery action of a kind this tool cannot name, and Windows carries it out once the process behind {0} is ended: {2}. See them with sc.exe qfailure",

"cli.run.progress": "[{0}/{1}] {2} {3} ...",
"cli.run.interrupted": "Interrupted. Finishing the step in flight, then putting back what was taken down. Another Ctrl+C leaves it as it is.",
Expand All @@ -102,6 +108,7 @@
"cli.run.outcome.succeeded": "done in {0}",
"cli.run.outcome.failed": "refused: {0} (error {1})",
"cli.run.outcome.stoppedWhileStarting": "did not start - it stopped again after {2}, exit code {1}: {0}",
"cli.run.outcome.startedAgain": "the process was ended, and Windows started the service again at once, in process {0}, after {1}",
"cli.run.outcome.timedOut": "gave up after {0}, still {1}",
"cli.run.outcome.timedOut.process": "gave up after {0}, still {1}, held by process {2}",
"cli.run.outcome.alreadyThere": "already there, nothing to do",
Expand All @@ -128,6 +135,12 @@
"cli.plan.problem.neighbourNeeded.one": "{1} is running and depends on an entry that shares the process of {0}, which ending that process would take down. There is no plan. Stop {1} first.",
"cli.plan.problem.neighbourNeeded.many": "These are running and depend on an entry that shares the process of {0}, which ending that process would take down: {1}. There is no plan. Stop them first.",
"cli.plan.problem.cascadeUnreadable":"Could not read everything that depends on {0} or on what shares its process, so the list of what ending its process would take down would be shorter than the truth. There is no preview this tool can honestly offer for that.",
"cli.plan.problem.processIsCritical": "Windows marks the process {0} runs in as critical. Ending it stops the whole machine with a blue screen, so there is no plan.",
"cli.plan.problem.recoveryRestartsComputer.one": "{1} has restart the computer among its recovery actions, and ending the process behind {0} is exactly the failure that sets them off. There is no plan. See them with sc.exe qfailure {1}",
"cli.plan.problem.recoveryRestartsComputer.many": "These have restart the computer among their recovery actions, and ending the process behind {0} is exactly the failure that sets them off: {1}. There is no plan. See them with sc.exe qfailure",
"cli.plan.problem.aftermathUnreadable.process": "Could not read whether the machine survives losing the process {0} runs in, so there is no plan.",
"cli.plan.problem.aftermathUnreadable.one": "Could not read what Windows does to {1} once its process dies - it could restart the computer - so there is no plan.",
"cli.plan.problem.aftermathUnreadable.many": "Could not read what Windows does to these once their process dies - it could restart the computer - so there is no plan: {1}",

"cli.column.name": "NAME",
"cli.column.displayName": "DISPLAY NAME",
Expand Down
89 changes: 82 additions & 7 deletions src/Bws.Core/EndingFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,32 +54,80 @@ namespace Bws.Core;
/// person can check against Task Manager. A file time in a preview would be noise carrying no
/// decision.
/// </param>
public readonly record struct EndingFacts(Reading<bool> CanBeEnded, Reading<long> Created)
/// <param name="Critical">
/// Whether Windows marked this process critical - ending one stops the whole machine with the stop
/// error <c>CRITICAL_PROCESS_DIED</c>, which is a different sentence from "a service the machine
/// needs" and a much shorter one to act on.
///
/// <b>Here since 2026-09-30, and the plan refuses on it</b> (stability report W-3, the owner's decision
/// of that day). Counted on the owner's machine that day: four of 114 processes behind services are
/// critical, and those four hold every one of the seven services whose recovery restarts the computer.
/// An unreadable answer is a refusal too - the one step nobody can undo is not previewed half blind.
/// </param>
public readonly record struct EndingFacts(Reading<bool> CanBeEnded, Reading<long> Created, Reading<bool> Critical)
{
/// <summary>
/// The honest answer when there was nobody to ask.
///
/// <b>A named value rather than a null, because "not asked" is a state this project has a word
/// for and null is not that word.</b> A plan built without a reader behaves exactly as it did
/// before either of these facts existed - it names the process, tries, and finds out. What it
/// before any of these facts existed - it names the process, tries, and finds out. What it
/// does NOT do is claim to have checked.
/// </summary>
public static EndingFacts NobodyAsked() =>
new(Reading<bool>.NotRead(), Reading<long>.NotRead());
new(Reading<bool>.NotRead(), Reading<long>.NotRead(), Reading<bool>.NotRead());
}

/// <summary>
/// Asks a process the two questions above.
/// One thing the manager does when an entry's process dies without the entry saying it stopped.
///
/// <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.
///
/// <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
/// count. So the plan asks what is ANYWHERE in the list.
/// </summary>
public enum RecoveryAction
{
/// <summary>An item that does nothing - Microsoft's own "take no action".</summary>
Nothing,

/// <summary>The manager starts the service again - the commonest: 204 of 312 services on one machine.</summary>
RestartService,

/// <summary>The manager runs the command the entry names.</summary>
RunProgram,

/// <summary>The manager restarts the computer.</summary>
RestartComputer,

/// <summary>
/// A type Microsoft does not document. Measured 2026-09-30: <c>Schedule</c> carries type 4 first,
/// and sc.exe prints nothing for it. Named rather than guessed at, and never left out.
/// </summary>
Unnamed
}

/// <summary>
/// Asks what ending a process would set off: the three questions above of the process, and what the
/// manager does afterwards to each entry living in it.
///
/// <b>Its own interface rather than a method on <see cref="IScmControl"/>, and the reason is the
/// promise section F of the specification makes.</b> Read-only mode is the absence of that
/// interface - one thing not to hold. Everything here is a read, so putting it there would mean a
/// read-only tool could not even show a preview of a forced stop, and previews are exactly what a
/// read-only tool should be able to show.
///
/// <b>It is also not part of <see cref="IScmCatalog"/></b>, which is about the service control
/// manager. These questions are asked of a PROCESS, and the manager has no opinion about them -
/// the same seam <see cref="IProcessMemoryReader"/> already draws for the same reason.
/// <b>It is not part of <see cref="IScmCatalog"/> either, and since 2026-09-30 that needs a better
/// reason than "these are asked of a process"</b>, because <see cref="ReadRecovery"/> is asked of the
/// manager. The reason is the subject: this interface answers "what happens if this process ends",
/// only a plan that ends one ever asks it, and both interfaces that build such a plan already hold
/// one. The catalog is the seam of the listing, with eight implementations, and a recovery reading
/// there would be the start of the phase 2 details rather than one plan's safety question.
/// </summary>
public interface IEndingFactsReader
{
Expand All @@ -93,4 +141,31 @@ public interface IEndingFactsReader
/// is a measurement rather than a setting.
/// </summary>
EndingFacts Read(int processId);

/// <summary>
/// What the manager does to this entry when its process dies, item by item.
///
/// <b>Absent when the entry is not there any more</b> - it went between the listing and this
/// question, so it will not die with anything. Denied when the manager refused, with its number,
/// and the plan refuses on that: a casualty list whose consequences are known to be missing is
/// the same shape as one known to be short.
/// </summary>
Reading<IReadOnlyList<RecoveryAction>> ReadRecovery(string serviceName);
}

/// <summary>
/// The reader a plan gets when there is nobody to ask - every answer is <see cref="ReadOutcome.NotRead"/>,
/// so the plan is built exactly as it was before any of these questions existed.
///
/// <b>An object rather than a null threaded through</b>, for the reason <see cref="EndingFacts.NobodyAsked"/>
/// gives: "not asked" is a state with a name here.
/// </summary>
internal sealed class NobodyToAsk : IEndingFactsReader
{
internal static readonly NobodyToAsk Instance = new();

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

public Reading<IReadOnlyList<RecoveryAction>> ReadRecovery(string serviceName) =>
Reading<IReadOnlyList<RecoveryAction>>.NotRead();
}
18 changes: 18 additions & 0 deletions src/Bws.Core/IScmControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,22 @@ public interface IScmControl

/// <summary>Where the entry is now. The answer says whether it could be read at all.</summary>
ControlAnswer Read(string serviceName);

/// <summary>
/// What every entry is doing and which process holds it, from one enumeration - asked by the step
/// that ends a process, immediately before it does.
///
/// <b>The same question <see cref="IScmCatalog.ReadStatuses"/> answers, on this side of the seam since
/// 2026-09-30</b> (stability report W-6, package B2). The runner holds nothing else, and the check it
/// feeds - who lives in the process NOW against who the plan said would die - is worked out above
/// this line where a test can drive it. <b>A reading rather than an exception on failure</b>, because
/// here a failure is not a broken listing: it is a step that refuses and ends nothing.
/// </summary>
Reading<IReadOnlyList<ScmStatus>> ReadStatuses();

/// <summary>
/// The entries that depend on this one, whatever they are doing, asked of the manager - the same
/// question and the same answer as <see cref="IScmCatalog.ReadDependents"/>, for the same step.
/// </summary>
Reading<IReadOnlyList<string>> ReadDependents(string serviceName);
}
Loading
Loading