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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ is not part of this repository.
- After a stop refused because other running services depend on the entry, or because of missing
rights, the window no longer offers Force stop, which could not help with either.
- The equivalent command of a force stop planned with its dependents includes `--dependents`.
- Restarting a service that takes longer than the limit to stop no longer leaves it stopped. The
limit - `--timeout`, and "Wait up to" on the plan sheet - now counts time without progress: a
service that keeps reporting progress is watched for as long as it takes, and one that sits still
is given up on once the limit has passed since it last moved. The start that brings a service
back waits for it to finish stopping instead of being refused while it is still stopping.
- A stop asked of a service that is already stopping waits for it instead of failing, and a start
asked of one still stopping waits for it to stop first and then starts it.
- A service that stops again while starting is reported at once as not started, with its exit code
and what Windows says about it, instead of after the whole limit as having run out of time.
- The plan to start a disabled service warns that Windows will refuse it, and says how to change
the startup type first. The plan to start a paused service warns that a start does not resume it.
- The second Ctrl+C during `bws stop`, `start`, `restart` or `kill` takes effect while a step is
still being waited for, instead of only after it.
- The note under a run that says the manager took longer than `--timeout` to answer appears only
when the manager really did, not for a step that simply kept making progress.
- 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.

## [0.3.0] - 2026-09-25

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ bws --version
| `--query TEXT` | on `list`, narrow the listing with the query language |
| `--dry-run` | print the plan and change nothing. The plan is the same one an execution runs |
| `--dependents` | put the services that would break into the plan as steps of their own |
| `--timeout SECONDS` | how long to wait for one step to reach the state it asked for, sixty unless you say otherwise. Running out is the end of watching, not a failure, and the report says where the entry was left |
| `--timeout SECONDS` | how long one step may go without progress before it is given up on, sixty unless you say otherwise. A service that keeps reporting progress is watched for as long as it takes. Running out is the end of watching, not a failure, and the report says where the entry was left |
| `--signatures` | read who signed each binary and whether Windows trusts it. Several seconds over a whole machine, so it is off unless asked, and a query about signatures turns it on by itself |
| `--memory` | read what each running entry's process is using. Off by default because it is a measurement, not a setting |
| `--required-by` | read which entries break if one is stopped, asked of Windows directly. A call per entry, so off unless asked. `show` and `snapshot create` always read it |
Expand Down
2 changes: 1 addition & 1 deletion site/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"switch.timing": "How long each part of the read took, on standard error.",
"switch.dry-run": "Print the plan and change nothing. It is the same plan an execution runs - there is no second code path for the real thing.",
"switch.dependents": "Put the services that would break into the plan as steps of their own.",
"switch.timeout": "How long to wait for one step to reach the state it asked for, counted from the moment the manager accepts the request. Sixty seconds unless you say otherwise. Running out is the end of watching rather than a failure, and the report says where the entry was left.",
"switch.timeout": "How long one step may go without progress before it is given up on, counted again every time the service reports progress. Sixty seconds unless you say otherwise. A service that keeps reporting progress is watched for as long as it takes. Running out is the end of watching rather than a failure, and the report says where the entry was left.",

"field.name": "the service name, the one Windows identifies it by",
"field.display": "the display name, as this Windows spells it",
Expand Down
2 changes: 1 addition & 1 deletion site/i18n/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"switch.timing": "Ile trwała każda część odczytu, na wyjściu błędów.",
"switch.dry-run": "Drukuje plan i nie zmienia niczego. To ten sam plan, który wykonuje się przy wykonaniu - nie ma drugiej ścieżki kodu dla wersji prawdziwej.",
"switch.dependents": "Wstawia do planu, jako osobne kroki, usługi, które przestaną działać.",
"switch.timeout": "Jak długo czekać, aż jeden krok osiągnie stan, o który poprosił, licząc od chwili przyjęcia żądania przez menedżera. Sześćdziesiąt sekund, o ile nie powiesz inaczej. Wyczerpanie tego czasu jest końcem patrzenia, a nie porażką - raport mówi, w jakim stanie wpis został zostawiony.",
"switch.timeout": "Jak długo jeden krok może trwać bez postępu, zanim narzędzie przestanie czekać - liczone od nowa za każdym razem, gdy usługa zamelduje postęp. Sześćdziesiąt sekund, o ile nie powiesz inaczej. Usługa, która melduje postęp, jest obserwowana tak długo, jak trzeba. Wyczerpanie tego czasu jest końcem patrzenia, a nie porażką - raport mówi, w jakim stanie wpis został zostawiony.",

"field.name": "nazwę usługi, tę, po której identyfikuje ją Windows",
"field.display": "nazwę wyświetlaną, tak jak pisze ją ten Windows",
Expand Down
2 changes: 1 addition & 1 deletion site/pages/cli-reference/en.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ <h3><code>list</code> and <code>show</code></h3>
</div>
<div>
<h3><code>stop</code>, <code>start</code> and <code>restart</code></h3>
<p>Each builds a plan and carries it out. <code>--dry-run</code> prints the plan and changes nothing, <code>--dependents</code> puts the services that would break into it as steps of their own, and <code>--timeout</code> says how long to wait for one step. Drivers are refused rather than attempted, because stopping a kernel driver is often not reversible without a restart.</p>
<p>Each builds a plan and carries it out. <code>--dry-run</code> prints the plan and changes nothing, <code>--dependents</code> puts the services that would break into it as steps of their own, and <code>--timeout</code> says how long one step may go without progress. Drivers are refused rather than attempted, because stopping a kernel driver is often not reversible without a restart.</p>
</div>
<div>
<h3><code>kill</code> - for a service that will not stop</h3>
Expand Down
2 changes: 1 addition & 1 deletion site/pages/cli-reference/pl.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ <h3><code>list</code> i <code>show</code></h3>
</div>
<div>
<h3><code>stop</code>, <code>start</code> i <code>restart</code></h3>
<p>Każdy buduje plan i go wykonuje. <code>--dry-run</code> drukuje plan i nie zmienia niczego, <code>--dependents</code> wstawia do niego usługi, które przestaną działać, jako osobne kroki, a <code>--timeout</code> mówi, jak długo czekać na jeden krok. Sterowniki są odmawiane, a nie próbowane, bo zatrzymanie sterownika jądra często nie jest odwracalne bez restartu.</p>
<p>Każdy buduje plan i go wykonuje. <code>--dry-run</code> drukuje plan i nie zmienia niczego, <code>--dependents</code> wstawia do niego usługi, które przestaną działać, jako osobne kroki, a <code>--timeout</code> mówi, jak długo jeden krok może trwać bez postępu. Sterowniki są odmawiane, a nie próbowane, bo zatrzymanie sterownika jądra często nie jest odwracalne bez restartu.</p>
</div>
<div>
<h3><code>kill</code> - dla usługi, która nie chce się zatrzymać</h3>
Expand Down
97 changes: 97 additions & 0 deletions src/Bws.Cli/PlanText.Warnings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using Bws.Core.Planning;

namespace Bws.Cli;

/// <summary>
/// The half of <see cref="PlanText"/> that words what a plan WARNS about, as against what it does and
/// what came of it.
///
/// <b>Its own file since 2026-09-30, and the size ratchet is what asked</b> - the two warnings of the
/// stability report's package C took PlanText.cs to 203 lines of code, one over the line where a file
/// counts as close to the ceiling. The seam is a subject rather than a count: every kind of warning
/// arrives here, one arm each, and nothing else in the class grows when one does.
/// </summary>
internal static partial class PlanText
{
/// <summary>
/// Turns a warning into words. The core reports a kind and the entries involved and
/// never a sentence, so the wording is decided here where it can be reviewed.
/// </summary>
internal static string Describe(PlanWarning warning) => warning.Kind switch
{
// Two keys apiece rather than "entry(s)". Text is a feature here, and a warning
// reading "1 other entries" spends trust that the warning itself needs.
PlanWarningKind.Cascade => Texts.Of(
Count("cli.plan.warning.cascade", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

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

// A PAIR SINCE 2026-09-01, AND NO NUMBER APPEARS IN EITHER SENTENCE - backlog 207. The
// window got both halves on 2026-08-19 and this one did not, so the two interfaces said
// different things about the same fact. "Those keep running" about a single entry is a
// plural with nothing to count it, which is the shape a scan for placeholders cannot see.
PlanWarningKind.SharedProcess => Texts.Of(
Count("cli.plan.warning.sharedProcess", warning), warning.ServiceName, Join(warning.Related)),

PlanWarningKind.ReturnsAfterReboot => Texts.Of(
"cli.plan.warning.returnsAfterReboot", warning.ServiceName),

PlanWarningKind.CascadeUnreadable => Texts.Of(
"cli.plan.warning.cascadeUnreadable", warning.ServiceName),

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

// NOT THE SHARED PROCESS SENTENCE, WHICH SAYS THE OPPOSITE. That one tells somebody
// the neighbours keep running, which is true of an ordinary stop and exactly wrong
// here - so the builder does not raise it for a forcing ask at all.
PlanWarningKind.TerminationTakesWithIt => Texts.Of(
Count("cli.plan.warning.takesWithIt", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

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

// SAME NAMES, DIFFERENT WHEN. The sentence above is about a machine going down while
// somebody watches, this one about a machine that comes up wrong weeks later. Sharing a
// sentence would have meant dropping the timing, which is the half that decides what an
// administrator does next.
PlanWarningKind.CriticalStartType => Texts.Of(
Count("cli.plan.warning.criticalStartType", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

PlanWarningKind.AlreadyThere => Texts.Of("cli.plan.warning.alreadyThere", warning.ServiceName),

// THE TERMINAL'S HALF OF THE OFFER. The window puts a button under this sentence and a
// terminal has none, so the sentence ends with the line that takes the offer - the same
// command with --stop, which is a whole plan of its own rather than a second command after.
PlanWarningKind.KeepsRunning => Texts.Of(
"cli.plan.warning.keepsRunning", warning.ServiceName,
EquivalentCommand.For(new ServiceAction(
ActionKind.SetStartType, warning.ServiceName, To: StartSetting.Disabled, AlsoStop: true))),

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.
_ => throw new ArgumentOutOfRangeException(
nameof(warning), warning.Kind, EquivalentCommand.Unhandled)
};
}
86 changes: 9 additions & 77 deletions src/Bws.Cli/PlanText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace Bws.Cli;
/// Every step says why it is there. "Stop these four services" invites a yes. "Stop these
/// three because they break otherwise, then the one you asked about" invites a decision.
/// </summary>
internal static class PlanText
internal static partial class PlanText
{
/// <summary>A plan that has not been carried out. The dry run, and nothing else.</summary>
internal static string Render(OperationPlan plan) => Render(plan, results: null);
Expand Down Expand Up @@ -98,7 +98,7 @@ private static string Render(
text.AppendLine(Texts.Of(
"cli.run.outranTheCeiling",
outran.Step.ServiceName,
Took(outran.Milliseconds),
Took(outran.Answered),
Took((long)run!.Ceiling.TotalMilliseconds)));
}

Expand Down Expand Up @@ -147,8 +147,13 @@ private static string Render(
StepOutcome.Succeeded => Texts.Of("cli.run.outcome.succeeded", Took(result.Milliseconds)),

// Never without words: a refusal is only ever built from a code and the system's own
// sentence for it, together.
StepOutcome.Failed => Texts.Of("cli.run.outcome.failed", result.Error!, result.ErrorCode),
// 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).
StepOutcome.Failed => result.StoppedWhileStarting
? Texts.Of(
"cli.run.outcome.stoppedWhileStarting", result.Error!, result.ErrorCode, Took(result.Milliseconds))
: Texts.Of("cli.run.outcome.failed", result.Error!, result.ErrorCode),

// THE ONE OUTCOME THAT NAMES THE PROCESS, SINCE 2026-09-06. A refusal already carries the
// manager's own number and sentence, and a step that arrived has nothing left to explain.
Expand Down Expand Up @@ -185,79 +190,6 @@ private static string Took(long milliseconds) => milliseconds < 1000
? Texts.Of("cli.run.took.milliseconds", milliseconds)
: Texts.Of("cli.run.took.seconds", (milliseconds / 1000d).ToString("0.#", CultureInfo.InvariantCulture));

/// <summary>
/// Turns a warning into words. The core reports a kind and the entries involved and
/// never a sentence, so the wording is decided here where it can be reviewed.
/// </summary>
internal static string Describe(PlanWarning warning) => warning.Kind switch
{
// Two keys apiece rather than "entry(s)". Text is a feature here, and a warning
// reading "1 other entries" spends trust that the warning itself needs.
PlanWarningKind.Cascade => Texts.Of(
Count("cli.plan.warning.cascade", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

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

// A PAIR SINCE 2026-09-01, AND NO NUMBER APPEARS IN EITHER SENTENCE - backlog 207. The
// window got both halves on 2026-08-19 and this one did not, so the two interfaces said
// different things about the same fact. "Those keep running" about a single entry is a
// plural with nothing to count it, which is the shape a scan for placeholders cannot see.
PlanWarningKind.SharedProcess => Texts.Of(
Count("cli.plan.warning.sharedProcess", warning), warning.ServiceName, Join(warning.Related)),

PlanWarningKind.ReturnsAfterReboot => Texts.Of(
"cli.plan.warning.returnsAfterReboot", warning.ServiceName),

PlanWarningKind.CascadeUnreadable => Texts.Of(
"cli.plan.warning.cascadeUnreadable", warning.ServiceName),

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

// NOT THE SHARED PROCESS SENTENCE, WHICH SAYS THE OPPOSITE. That one tells somebody
// the neighbours keep running, which is true of an ordinary stop and exactly wrong
// here - so the builder does not raise it for a forcing ask at all.
PlanWarningKind.TerminationTakesWithIt => Texts.Of(
Count("cli.plan.warning.takesWithIt", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

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

// SAME NAMES, DIFFERENT WHEN. The sentence above is about a machine going down while
// somebody watches, this one about a machine that comes up wrong weeks later. Sharing a
// sentence would have meant dropping the timing, which is the half that decides what an
// administrator does next.
PlanWarningKind.CriticalStartType => Texts.Of(
Count("cli.plan.warning.criticalStartType", warning),
warning.ServiceName, warning.Related.Count, Join(warning.Related)),

PlanWarningKind.AlreadyThere => Texts.Of("cli.plan.warning.alreadyThere", warning.ServiceName),

// THE TERMINAL'S HALF OF THE OFFER. The window puts a button under this sentence and a
// terminal has none, so the sentence ends with the line that takes the offer - the same
// command with --stop, which is a whole plan of its own rather than a second command after.
PlanWarningKind.KeepsRunning => Texts.Of(
"cli.plan.warning.keepsRunning", warning.ServiceName,
EquivalentCommand.For(new ServiceAction(
ActionKind.SetStartType, warning.ServiceName, To: StartSetting.Disabled, AlsoStop: true))),

PlanWarningKind.StartsAtNextBoot => Texts.Of("cli.plan.warning.startsAtNextBoot", 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.
_ => throw new ArgumentOutOfRangeException(
nameof(warning), warning.Kind, EquivalentCommand.Unhandled)
};

/// <summary>
/// A refusal in words.
///
Expand Down
Loading
Loading