diff --git a/CHANGELOG.md b/CHANGELOG.md
index beeb024..c07ea92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index bf1b616..5f790b3 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/site/i18n/en.json b/site/i18n/en.json
index 0918c92..04f458a 100644
--- a/site/i18n/en.json
+++ b/site/i18n/en.json
@@ -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",
diff --git a/site/i18n/pl.json b/site/i18n/pl.json
index 4d732c9..2a06643 100644
--- a/site/i18n/pl.json
+++ b/site/i18n/pl.json
@@ -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",
diff --git a/site/pages/cli-reference/en.html b/site/pages/cli-reference/en.html
index 545a50d..a786747 100644
--- a/site/pages/cli-reference/en.html
+++ b/site/pages/cli-reference/en.html
@@ -48,7 +48,7 @@
list and show
stop, start and restart
-
Each builds a plan and carries it out. --dry-run prints the plan and changes nothing, --dependents puts the services that would break into it as steps of their own, and --timeout 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.
+
Each builds a plan and carries it out. --dry-run prints the plan and changes nothing, --dependents puts the services that would break into it as steps of their own, and --timeout 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.
kill - for a service that will not stop
diff --git a/site/pages/cli-reference/pl.html b/site/pages/cli-reference/pl.html
index e501a2f..66752b9 100644
--- a/site/pages/cli-reference/pl.html
+++ b/site/pages/cli-reference/pl.html
@@ -41,7 +41,7 @@ list i show
stop, start i restart
-
Każdy buduje plan i go wykonuje. --dry-run drukuje plan i nie zmienia niczego, --dependents wstawia do niego usługi, które przestaną działać, jako osobne kroki, a --timeout 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.
+
Każdy buduje plan i go wykonuje. --dry-run drukuje plan i nie zmienia niczego, --dependents wstawia do niego usługi, które przestaną działać, jako osobne kroki, a --timeout 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.
kill - dla usługi, która nie chce się zatrzymać
diff --git a/src/Bws.Cli/PlanText.Warnings.cs b/src/Bws.Cli/PlanText.Warnings.cs
new file mode 100644
index 0000000..cbd6a01
--- /dev/null
+++ b/src/Bws.Cli/PlanText.Warnings.cs
@@ -0,0 +1,97 @@
+using Bws.Core.Planning;
+
+namespace Bws.Cli;
+
+///
+/// The half of that words what a plan WARNS about, as against what it does and
+/// what came of it.
+///
+/// Its own file since 2026-09-30, and the size ratchet is what asked - 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.
+///
+internal static partial class PlanText
+{
+ ///
+ /// 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.
+ ///
+ 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)
+ };
+}
diff --git a/src/Bws.Cli/PlanText.cs b/src/Bws.Cli/PlanText.cs
index 0eab51f..f642bef 100644
--- a/src/Bws.Cli/PlanText.cs
+++ b/src/Bws.Cli/PlanText.cs
@@ -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.
///
-internal static class PlanText
+internal static partial class PlanText
{
///
A plan that has not been carried out. The dry run, and nothing else.
internal static string Render(OperationPlan plan) => Render(plan, results: null);
@@ -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)));
}
@@ -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.
@@ -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));
- ///
- /// 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.
- ///
- 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)
- };
-
///
/// A refusal in words.
///
diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json
index 8d57134..3b39a87 100644
--- a/src/Bws.Cli/Resources/cli.en.json
+++ b/src/Bws.Cli/Resources/cli.en.json
@@ -87,18 +87,21 @@
"cli.plan.warning.alreadyThere": "{0} is already in that state, so nothing would change.",
"cli.plan.warning.keepsRunning": "{0} is running, and a start type does not stop it. It keeps running until it is stopped or the machine restarts. To stop it in the same plan: {1}",
"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.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.",
"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} took {1}, longer than the --timeout of {2}. That switch caps how long this tool waits once the manager has accepted a request. It cannot cap the manager's own answer, and here the manager took that long to answer - which it does when a service never reports itself to it.",
+ "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.putBack.heading": "To put the machine back the way this run found it, in this order:",
"cli.run.putBack.line": " {0}",
"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.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",
diff --git a/src/Bws.Core/Clock.cs b/src/Bws.Core/Clock.cs
index 318c288..a3b9c24 100644
--- a/src/Bws.Core/Clock.cs
+++ b/src/Bws.Core/Clock.cs
@@ -25,8 +25,8 @@ public interface IClock
///
/// A count that only ever goes forward, for measuring how long something took.
///
- /// Added 2026-09-03 for backlog 299. A plan step is watched for up to a minute -
- /// Carrying.Ceiling - and the machines this project tests on are virtual ones,
+ /// Added 2026-09-03 for backlog 299. A plan step is watched for as long as its entry
+ /// keeps moving - StepCeiling - and the machines this project tests on are virtual ones,
/// which is exactly the family where the wall clock jumps on resume. Two readings of
/// across such a jump give a deadline that has already passed, a step
/// reported as timed out that was fine, and a milliseconds figure in the machine
diff --git a/src/Bws.Core/IScmControl.cs b/src/Bws.Core/IScmControl.cs
index cdd0b66..362f8b1 100644
--- a/src/Bws.Core/IScmControl.cs
+++ b/src/Bws.Core/IScmControl.cs
@@ -21,8 +21,22 @@ namespace Bws.Core;
/// "still stopping, process 4812" are the same fact with and without somewhere to go next.
/// Zero becomes an absence one layer up, where the four read states live.
///
+///
+/// The Windows error number the service gave when it last stopped or failed to start, zero when it
+/// gave none - dwWin32ExitCode, raw, from the same structure as everything else here.
+///
+/// Here since 2026-09-30 so that a start which fell over can say why (stability report W-7).
+/// Before that a service that died while starting sat in Stopped with no wait hint, and the step
+/// watched it for the whole limit and reported that it ran out of time - a sentence about waiting,
+/// over a service that had already told the manager exactly what went wrong.
+///
+///
+/// The service's own number, meaningful only when is 1066 - Windows'
+/// "the service has returned a service-specific error code". Not a Windows error number, so it is
+/// never mixed with one.
+///
public readonly record struct ServiceProgress(
- EntryStatus Status, uint CheckPoint, TimeSpan WaitHint, uint ProcessId);
+ EntryStatus Status, uint CheckPoint, TimeSpan WaitHint, uint ProcessId, uint ExitCode = 0, uint ServiceExitCode = 0);
///
/// What the manager said. Facts only - the wording belongs to the layer above, same as
diff --git a/src/Bws.Core/Planning/PlanBuilder.cs b/src/Bws.Core/Planning/PlanBuilder.cs
index 3a16dce..d1e90ad 100644
--- a/src/Bws.Core/Planning/PlanBuilder.cs
+++ b/src/Bws.Core/Planning/PlanBuilder.cs
@@ -510,6 +510,7 @@ private void AddWarnings(
// The same pitfall from the other side - UX-GUI-006 and spec C4.
StartTypeWarnings.Add(warnings, target, action);
+ RefusedStartWarnings.Add(warnings, target, action);
// THE TWO SENTENCES ONLY A FORCING ASK PRODUCES, and neither of them is the shared process
// warning above - that one does not fire for these kinds at all, because it says the
diff --git a/src/Bws.Core/Planning/PlanRun.cs b/src/Bws.Core/Planning/PlanRun.cs
index dbe2fca..f79b121 100644
--- a/src/Bws.Core/Planning/PlanRun.cs
+++ b/src/Bws.Core/Planning/PlanRun.cs
@@ -100,6 +100,30 @@ public sealed record StepResult
/// How long this step took, waiting included.
public required long Milliseconds { get; init; }
+ ///
+ /// How long the manager took to answer the request itself, before any watching - zero for a step
+ /// that asked nothing.
+ ///
+ /// Here since 2026-09-30 because could no longer be read
+ /// off . The limit counts time WITHOUT PROGRESS from that day, so a
+ /// step reporting progress for three minutes under a limit of one is ordinary, and the one thing
+ /// the limit still cannot reach is the manager sitting on the request. Not in the machine readable
+ /// output - it feeds a sentence, not a field anybody asked for.
+ ///
+ public long Answered { get; init; }
+
+ ///
+ /// The entry was on its way to Running and fell back to Stopped - the service stopped while it was
+ /// starting. is then the service's own exit code rather than the manager's
+ /// refusal, and the two interfaces word it that way.
+ ///
+ /// A fact rather than a sentence, since 2026-09-30 (stability report W-7): the failed step's
+ /// other fields cannot tell it apart from a start the manager refused, which also leaves the entry
+ /// Stopped with an error number. Not in the machine readable output, on the owner's decision of that
+ /// day - the number travels in errorCode and the words in error.
+ ///
+ public bool StoppedWhileStarting { get; init; }
+
/// The entry is where the step wanted it, whether or not we had to do anything.
public bool Arrived =>
Outcome == StepOutcome.Succeeded
@@ -125,7 +149,9 @@ public sealed record PlanRun
public required bool Cancelled { get; init; }
///
- /// The longest any one step was to be watched for, as asked for by whoever ran this.
+ /// How long any one step could go without progress before it was given up on, as asked for by
+ /// whoever ran this. Until 2026-09-30 it was the longest any one step was watched for at all - the
+ /// owner's decision of 2026-09-29 (stability report W-1) made it count from the last progress.
///
/// Here rather than left with the caller because it is half of the evidence this record
/// exists to hold: what came of a step is only readable next to what it was given.
@@ -133,28 +159,30 @@ public sealed record PlanRun
public required TimeSpan Ceiling { get; init; }
///
- /// Steps that took longer than the ceiling and did not end by our giving up.
+ /// Steps where the manager alone took longer than the ceiling to answer the request.
///
/// This is a real case and it surprises people, which is why it is a property rather
- /// than something a reader is left to spot. --timeout caps how long this tool
- /// waits after the manager accepts a request. It cannot cap the manager's own
+ /// than something a reader is left to spot. --timeout governs the watching
+ /// after the manager accepts a request. It cannot cap the manager's own
/// answer, and the manager does not always answer quickly: measured on Windows Server
/// 2025 on 2026-08-04, StartService for a service that never reports itself took
/// 30 375-30 450 ms across three runs before coming back with error 1053. So
/// bws start X --timeout 1 ran for half a minute, reported the truth, and looked
/// like a switch that did nothing.
///
- /// The test carries no threshold on purpose. A step that ended in
- /// reached the ceiling because the ceiling worked, and
- /// its own line already says "gave up after". Any other step that ran past the ceiling
- /// spent that time somewhere the ceiling does not reach, and that is the whole of what
- /// there is to say. Picking a multiple of the ceiling instead would have been a number
- /// with no reason behind it.
+ /// READ OFF SINCE 2026-09-30, AND UNTIL THEN OFF THE WHOLE
+ /// STEP. The whole step used to be the right measure, because the ceiling capped the whole
+ /// watch and anything over it had to have been spent in the manager. From that day the ceiling
+ /// counts time without progress, so a stop reporting progress for ninety seconds under a limit of
+ /// sixty is the limit working - and the old measure would have told somebody the manager took
+ /// ninety seconds to answer, which it did not.
+ ///
+ /// The test carries no threshold on purpose. An answer longer than the ceiling was
+ /// spent somewhere the ceiling does not reach, and that is the whole of what there is to say.
+ /// Picking a multiple of the ceiling instead would have been a number with no reason behind it.
///
public IReadOnlyList OutranTheCeiling =>
- [.. Results.Where(result =>
- result.Outcome != StepOutcome.TimedOut
- && result.Milliseconds > Ceiling.TotalMilliseconds)];
+ [.. Results.Where(result => result.Answered > Ceiling.TotalMilliseconds)];
///
/// Every entry ended up where the plan wanted it.
diff --git a/src/Bws.Core/Planning/PlanRunner.Waiting.cs b/src/Bws.Core/Planning/PlanRunner.Waiting.cs
index e1ead0c..3153172 100644
--- a/src/Bws.Core/Planning/PlanRunner.Waiting.cs
+++ b/src/Bws.Core/Planning/PlanRunner.Waiting.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
namespace Bws.Core.Planning;
///
@@ -46,46 +48,135 @@ public sealed partial class PlanRunner
internal static readonly TimeSpan FirstLook = TimeSpan.FromMilliseconds(10);
///
- /// Watches an entry on its way, and decides when it has stopped going anywhere.
+ /// What a step does before it asks for anything, when the entry is already on its way somewhere -
+ /// or null, when it should go on and ask.
+ ///
+ /// New on 2026-09-30, on the owner's decision of 2026-09-29 (stability report W-1 and W-7).
+ /// Until then a step asked whenever the entry was not where it wanted it, and the manager refuses
+ /// both halves of this: a stop to an entry already stopping, and a start to one still stopping.
+ /// The second is how a restart used to end. The stop gave up on a service that was still honestly
+ /// stopping, the start that puts it back was refused, and the service finished stopping after the
+ /// run had left and stayed stopped.
///
- /// Two deadlines, and the entry's own comes first. Win32 documents the promise: before
- /// its wait hint elapses a service will either raise its check point or change state.
- /// Keeping that promise buys it a fresh wait hint, so an entry that genuinely needs a
- /// minute gets one. Breaking it is the documented signal that something has gone wrong.
- /// The cap is ours and only stops a plan hanging a terminal on an entry that reports
- /// progress it never finishes.
+ /// Already heading where the step wants it: nothing is asked, the entry is watched, and if it
+ /// arrives the step succeeded - which is what the outcome has always meant, the entry reaching the
+ /// state the step asked for, not this tool sending it there. Heading the other way: it is
+ /// watched until it leaves that state, and the step then goes on as though it had just found it
+ /// there. One that never leaves it is a failure with nothing sent, in our own words, because the
+ /// way back must not count as a move something this tool never asked for.
///
- private StepResult WaitFor(PlanStep step, EntryStatus target, TimeSpan timeout, TimeSpan started)
+ private StepResult? Settle(
+ PlanStep step, EntryStatus target, ControlAnswer before, TimeSpan timeout, TimeSpan started, Halt halt)
{
- var giveUpAt = started + timeout;
- var pause = FirstLook;
- var status = EntryStatus.Unknown;
+ var status = before.Progress!.Value.Status;
+ bool Unasked() => halt.Unasked(step.Reason);
+
+ if (status != Leaving(target))
+ {
+ return OnItsWay(target, status) ? WaitFor(step, target, timeout, started, Unasked, asked: false) : null;
+ }
+
+ var watched = Watch(step.ServiceName, now => now != status, timeout, Unasked);
+ var seen = watched.Seen ?? watched.Last;
+
+ return watched.End switch
+ {
+ Settled.Over when Where(seen) == target =>
+ Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)),
+
+ Settled.Over when OnItsWay(target, Where(seen)) =>
+ WaitFor(step, target, timeout, started, Unasked, asked: false),
+
+ Settled.Over => null,
+ Settled.Unreadable => Refused(step, watched.Last, Where(seen), Holding(seen), started),
+ Settled.Halted => Skipped(step, SkipReason.Cancelled, Where(seen), Holding(seen), Elapsed(started)),
+ _ => Refused(step, ControlAnswer.Refused(0, NeverAsked(target)), Where(seen), Holding(seen), started)
+ };
+ }
+
+ ///
+ /// Watches an entry on its way to where the step wants it, and says what came of it.
+ ///
+ /// A start that falls back to Stopped is over, and it is a failure with the service's own
+ /// number - since 2026-09-30, stability report W-7. The manager marks an entry start pending
+ /// before the start call returns, so Stopped after that is the service having stopped, and the
+ /// exit code it left is the only honest answer. Until that day such a start was watched for the
+ /// whole limit and reported as having run out of time. A stop that goes back to Running is NOT
+ /// treated the same way: a service may take the control and only report stop pending a moment
+ /// later, so Running straight after a stop is ordinary.
+ ///
+ /// When the watching ends because somebody asked the run to stop.
+ ///
+ /// Whether the manager was asked anything. An abandoned watch after a request reports that the
+ /// watching ended - the entry may still arrive, which is what TimedOut says - and one before any
+ /// request is a step never attempted.
+ ///
+ private StepResult WaitFor(
+ PlanStep step, EntryStatus target, TimeSpan timeout, TimeSpan started, Func halted, bool asked)
+ {
+ var watched = Watch(step.ServiceName, now => now == target || FellBack(target, now), timeout, halted);
- // Carried alongside the status rather than read again at the end, because the point of it
- // is the step that gives up: by then the entry is exactly where nobody can act on it, and
- // the last process seen holding it is the only handle a person has on what to do next.
- var processId = Reading.NotRead();
+ // The last reading that worked rather than the one that ended it, because the point of both
+ // is the step that gives up: by then the entry is where nobody can act on it, and the last
+ // process seen holding it is the only handle a person has on what to do next.
+ var seen = watched.Seen ?? watched.Last;
+
+ return watched.End switch
+ {
+ Settled.Unreadable => Refused(step, watched.Last, Where(seen), Holding(seen), started),
+ Settled.Over when Where(seen) == target =>
+ Result(step, StepOutcome.Succeeded, target, Holding(seen), Elapsed(started)),
+
+ Settled.Over => StoppedAgain(step, seen, started),
+ Settled.Halted when !asked =>
+ Skipped(step, SkipReason.Cancelled, Where(seen), Holding(seen), Elapsed(started)),
+
+ _ => Result(step, StepOutcome.TimedOut, Where(seen), Holding(seen), Elapsed(started))
+ };
+ }
+
+ ///
+ /// Asks where an entry is until it reaches a state accepts, stops going
+ /// anywhere, cannot be read, or says to stop looking.
+ ///
+ /// Two deadlines, and the entry's own comes first. Win32 documents the promise: before its
+ /// wait hint elapses a service will either raise its check point or change state. Keeping that
+ /// promise buys it a fresh wait hint, so an entry that genuinely needs a minute gets one. Breaking
+ /// it is the documented signal that something has gone wrong.
+ ///
+ /// The limit is ours, and since 2026-09-30 it is counted from the LAST PROGRESS rather than
+ /// from the start of the step - the owner's decision of 2026-09-29, stability report W-1. It
+ /// decides alone when the entry promises nothing, and it never gives up on an entry that keeps
+ /// moving. Until that day it was a wall across the whole step, and a service stopping honestly for
+ /// seventy seconds was given up on at sixty.
+ ///
+ private Watched Watch(string serviceName, Func over, TimeSpan timeout, Func halted)
+ {
+ var pause = FirstLook;
+ ControlAnswer? seen = null;
uint? checkPoint = null;
+ var movedAt = TimeSpan.Zero;
TimeSpan? promisedBy = null;
while (true)
{
- var answer = control.Read(step.ServiceName);
+ var answer = control.Read(serviceName);
if (!answer.Worked)
{
- return Refused(step, answer, status, processId, started);
+ return new Watched(Settled.Unreadable, answer, seen);
}
var progress = answer.Progress!.Value;
- var moved = checkPoint is null || progress.CheckPoint > checkPoint || progress.Status != status;
+ var moved = seen is null
+ || progress.CheckPoint > checkPoint
+ || progress.Status != seen.Progress!.Value.Status;
- status = progress.Status;
- processId = Holding(answer);
+ seen = answer;
- if (status == target)
+ if (over(progress.Status))
{
- return Result(step, StepOutcome.Succeeded, status, processId, Elapsed(started));
+ return new Watched(Settled.Over, answer, seen);
}
var now = clock.Elapsed;
@@ -93,15 +184,21 @@ private StepResult WaitFor(PlanStep step, EntryStatus target, TimeSpan timeout,
if (moved)
{
checkPoint = progress.CheckPoint;
+ movedAt = now;
// A wait hint of zero is what an entry reports when it has nothing pending,
- // so it is not a promise to hold anybody to. Then only our own cap applies.
+ // so it is not a promise to hold anybody to. Then only our own limit applies.
promisedBy = progress.WaitHint > TimeSpan.Zero ? now + Honoured(progress.WaitHint) : null;
}
- if (now >= giveUpAt || (promisedBy is not null && now >= promisedBy))
+ if (now >= movedAt + timeout || (promisedBy is not null && now >= promisedBy))
{
- return Result(step, StepOutcome.TimedOut, status, processId, Elapsed(started));
+ return new Watched(Settled.GaveUp, answer, seen);
+ }
+
+ if (halted())
+ {
+ return new Watched(Settled.Halted, answer, seen);
}
clock.Wait(pause);
@@ -109,6 +206,101 @@ private StepResult WaitFor(PlanStep step, EntryStatus target, TimeSpan timeout,
}
}
+ /// The state that means the entry is on its way somewhere else than the step wants it.
+ private static EntryStatus Leaving(EntryStatus target) =>
+ target == EntryStatus.Running ? EntryStatus.StopPending : EntryStatus.StartPending;
+
+ ///
+ /// Whether the entry is already heading where the step wants it. Continue pending is a paused
+ /// service being resumed, which ends in Running.
+ ///
+ private static bool OnItsWay(EntryStatus target, EntryStatus status) => target == EntryStatus.Running
+ ? status is EntryStatus.StartPending or EntryStatus.ContinuePending
+ : status == EntryStatus.StopPending;
+
+ private static bool FellBack(EntryStatus target, EntryStatus status) =>
+ target == EntryStatus.Running && status == EntryStatus.Stopped;
+
+ ///
+ /// A start that ended in Stopped, with the service's own exit code as the number.
+ ///
+ /// The words are the system's for that number, the same as for any refusal. For 1066 -
+ /// "the service has returned a service-specific error code" - the service's own number follows in
+ /// brackets, because the system's words say there is one and not what it is. It is not a Windows
+ /// error number, so it never goes where one is expected - the owner's decision of 2026-09-30.
+ ///
+ private StepResult StoppedAgain(PlanStep step, ControlAnswer seen, TimeSpan started)
+ {
+ var progress = seen.Progress!.Value;
+ var code = unchecked((int)progress.ExitCode);
+ var words = ManagerTerms.Describe(code);
+
+ return Result(step, StepOutcome.Failed, progress.Status, Holding(seen), Elapsed(started)) with
+ {
+ ErrorCode = code,
+ Error = code == ServiceSpecific
+ ? string.Create(CultureInfo.InvariantCulture, $"{words} ({progress.ServiceExitCode})")
+ : words,
+ StoppedWhileStarting = true
+ };
+ }
+
+ /// ERROR_SERVICE_SPECIFIC_ERROR - the service's own number is in the second field.
+ private const int ServiceSpecific = 1066;
+
+ ///
+ /// Said in the plainest words available, because it is a refusal of ours rather than the system's -
+ /// the same shape as . Nothing was sent, and the sentence says so.
+ ///
+ private static string NeverAsked(EntryStatus target) => target == EntryStatus.Running
+ ? "It was still stopping when the waiting ran out, so it was never asked to start. "
+ + "Ask again once it has stopped."
+ : "It was still starting when the waiting ran out, so it was never asked to stop. "
+ + "Ask again once it has started.";
+
+ /// How one watch ended.
+ private enum Settled
+ {
+ /// The entry reached a state the watch was waiting for.
+ Over,
+
+ /// It stopped going anywhere - its own promise broke, or the limit passed without progress.
+ GaveUp,
+
+ /// Somebody asked the run to stop in a way this watch honours.
+ Halted,
+
+ /// A reading was refused.
+ Unreadable
+ }
+
+ ///
+ /// How a watch ended, the answer that ended it, and the last reading that worked - null when none
+ /// did. A refused reading ends a watch without a status, and the status worth reporting is the
+ /// one before it.
+ ///
+ private readonly record struct Watched(Settled End, ControlAnswer Last, ControlAnswer? Seen);
+
+ ///
+ /// The two ways a run can be told to stop, as a step being watched sees them.
+ ///
+ /// Before anything is asked, exactly as a step not yet reached - the rule in
+ /// , so the two can never disagree. After the manager was asked, only
+ /// abandonment, because a service told to stop does not un-stop, and the first level promises
+ /// to watch what it already asked for.
+ ///
+ private readonly record struct Halt(CancellationToken Interruption, CancellationToken Abandonment)
+ {
+ internal bool Unasked(StepReason reason) => Held(
+ reason,
+ Abandonment.IsCancellationRequested,
+ Interruption.IsCancellationRequested,
+ forwardFailed: false,
+ cascadeFailed: false) is not null;
+
+ internal bool Asked => Abandonment.IsCancellationRequested;
+ }
+
///
/// How long an entry's promise is held open: its wait hint, rounded UP to a whole
/// .
diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs
index 236b18f..6a5e334 100644
--- a/src/Bws.Core/Planning/PlanRunner.cs
+++ b/src/Bws.Core/Planning/PlanRunner.cs
@@ -20,19 +20,28 @@ public sealed partial class PlanRunner(IScmControl control, IClock clock)
/// Runs every step, in order.
///
///
- /// The longest we will watch any one step. A cap rather than the deadline - the entry's
- /// own wait hint usually decides first, and this stops a plan hanging a terminal when an
- /// entry keeps reporting progress forever.
+ /// How long any one step may go WITHOUT PROGRESS - its check point not rising and its state not
+ /// changing - before it is given up on. The entry's own wait hint usually decides first.
+ ///
+ /// Counted from the last progress since 2026-09-30, on the owner's decision of 2026-09-29
+ /// (stability report W-1). Until then it was the longest any step was watched at all, which gave
+ /// up on a service that took seventy honest seconds to stop - and a restart then asked a service
+ /// still stopping to start, was refused, and left it stopped. The price is said out loud rather
+ /// than hidden: an entry that reports progress forever is now watched forever. The terminal gets
+ /// out of that with a second Ctrl+C, and the window has no way out yet (backlog 497).
///
///
/// Stop going forward. The steps that put things back are still carried out.
///
- /// Checked between steps, not during one. A step already asked for is watched to its
- /// end, because a service told to stop does not un-stop, and reporting a step we stopped
- /// looking at would be a claim about something nobody saw.
+ /// Checked between steps, and while a step is waiting to be ASKED - an entry still on its way
+ /// somewhere when the step reaches it. A step already asked for is watched to its end, because a
+ /// service told to stop does not un-stop, and reporting a step we stopped looking at would be a
+ /// claim about something nobody saw.
///
///
- /// Stop altogether, putting nothing back.
+ /// Stop altogether, putting nothing back - between steps and, since 2026-09-30, while a step is
+ /// being watched (stability report W-11). A step abandoned after it was asked for reports that
+ /// the watching ended rather than that it failed, because the entry may still arrive.
///
/// Separate from because they are different asks and the
/// second one is expensive: it is how somebody ends up with half a cascade down. It
@@ -101,7 +110,7 @@ public PlanRun Run(
starting?.Invoke(step, index + 1);
- var result = RunStep(step, timeout);
+ var result = RunStep(step, timeout, new Halt(cancellation, abandonment));
results.Add(result);
@@ -196,7 +205,7 @@ private bool Stays(OperationPlan plan)
return !(holding.IsPresent && holding.Value == ending.ProcessId);
}
- private StepResult RunStep(PlanStep step, TimeSpan timeout)
+ private StepResult RunStep(PlanStep step, TimeSpan timeout, Halt halt)
{
// THE RULER RATHER THAN THE WALL CLOCK, SINCE 2026-09-03 - backlog 299. Everything below
// asks how long, never what time, and two readings of a wall clock across a machine
@@ -227,13 +236,24 @@ private StepResult RunStep(PlanStep step, TimeSpan timeout)
return Skipped(step, SkipReason.AlreadyThere, target, Holding(before), Elapsed(started));
}
+ // AN ENTRY ALREADY ON ITS WAY IS WAITED FOR BEFORE ANYTHING IS ASKED, since 2026-09-30 - Settle
+ // says how. Not for an ending: that step exists for the entry stuck in StopPending, and waiting
+ // for it to finish stopping would be the one wrong answer there.
+ if (step.Operation != StepOperation.Terminate
+ && Settle(step, target, before, timeout, started, halt) is { } settled)
+ {
+ return settled;
+ }
+
// ENDING A PROCESS DOES NOT GO THROUGH THE MANAGER, so it is not a Request - and the reading
- // taken four lines up is the whole reason this sits here rather than anywhere else. It is
- // the freshest answer available about where the entry is and what is holding it, taken
- // immediately before anything happens.
+ // taken above is the whole reason this sits here rather than anywhere else. It is the freshest
+ // answer available about where the entry is and what is holding it, taken immediately before
+ // anything happens.
+ var asking = clock.Elapsed;
var request = step.Operation == StepOperation.Terminate
? End(step, before)
: control.Request(step.ServiceName, step.Operation);
+ var answered = Elapsed(asking);
if (!request.Worked)
{
@@ -245,13 +265,15 @@ private StepResult RunStep(PlanStep step, TimeSpan timeout)
if (after.Worked && after.Progress!.Value.Status == target)
{
- return Skipped(step, SkipReason.AlreadyThere, target, Holding(after), Elapsed(started));
+ return Skipped(step, SkipReason.AlreadyThere, target, Holding(after), Elapsed(started))
+ with { Answered = answered };
}
- return Refused(step, request, Where(after), Holding(after), started);
+ return Refused(step, request, Where(after), Holding(after), started) with { Answered = answered };
}
- return WaitFor(step, target, timeout, started);
+ return WaitFor(step, target, timeout, started, () => halt.Asked, asked: true)
+ with { Answered = answered };
}
///
diff --git a/src/Bws.Core/Planning/PlanWarnings.cs b/src/Bws.Core/Planning/PlanWarnings.cs
index ed57648..037123a 100644
--- a/src/Bws.Core/Planning/PlanWarnings.cs
+++ b/src/Bws.Core/Planning/PlanWarnings.cs
@@ -148,7 +148,31 @@ public enum PlanWarningKind
/// as well is not proposed - that would be this tool adding an ask nobody specified. Only where
/// the state was read as stopped. Added 2026-09-24.
///
- StartsAtNextBoot
+ StartsAtNextBoot,
+
+ ///
+ /// The plan starts an entry read as Disabled and stopped - and the manager refuses to start a
+ /// disabled entry.
+ ///
+ /// A warning rather than a refusal, on the owner's decision of 2026-09-30 (stability report
+ /// W-8), and the line is the one holds: we say what we already
+ /// read and the manager stays the authority on what it accepts. Refusing is kept for taking down
+ /// something that could not be put back, which a start is not. Measured before the change on
+ /// 2026-09-29: bws start on a disabled entry of this machine gave a plan of one step, no
+ /// warning and exit code 0.
+ ///
+ DisabledCannotStart,
+
+ ///
+ /// The plan starts an entry read as Paused or pausing - and a start does not resume a paused
+ /// service, so the manager refuses it.
+ ///
+ /// The same line as , added the same day (stability report
+ /// W-7), and checked against it before either was written: both say what was read and predict
+ /// nothing the reading does not already say. Resuming is a write of its own that this tool does not
+ /// have, so the sentence names the system's way to do it.
+ ///
+ PausedCannotStart
}
///
diff --git a/src/Bws.Core/Planning/RefusedStartWarnings.cs b/src/Bws.Core/Planning/RefusedStartWarnings.cs
new file mode 100644
index 0000000..6fb3a63
--- /dev/null
+++ b/src/Bws.Core/Planning/RefusedStartWarnings.cs
@@ -0,0 +1,42 @@
+namespace Bws.Core.Planning;
+
+///
+/// What a plan that starts an entry says when what was read already names the manager's refusal.
+///
+/// Added 2026-09-30, stability report W-7 and W-8, on the owner's decision of that day. A start
+/// of a disabled entry and a start of a paused one are both refused by the manager every time, and
+/// until that day both plans were one step with nothing under it - the refusal arrived as a number
+/// after the button was pressed.
+///
+/// A class of its own for the same reason as : the builder's
+/// warning method stands near its ceilings, and the subject is its own - everything there is about
+/// what a plan does, and this is about a start the machine is known to turn down.
+///
+/// Only a plain start. A restart of a disabled entry is refused outright before any warning
+/// is worked out (PlanProblemKind.CannotComeBack), and a restart of a paused one stops it first,
+/// which a paused service accepts.
+///
+internal static class RefusedStartWarnings
+{
+ internal static void Add(List warnings, ScmEntry target, ServiceAction action)
+ {
+ if (action.Kind != ActionKind.Start)
+ {
+ return;
+ }
+
+ // Stopped or on its way there - an entry already starting is waited for rather than asked, and
+ // one that is running needs no start at all. Only where the start type was READ, because an
+ // unreadable one is not "disabled".
+ if (target.StartType is { IsPresent: true, Value: StartType.Disabled }
+ && target.Status is EntryStatus.Stopped or EntryStatus.StopPending)
+ {
+ warnings.Add(new PlanWarning(PlanWarningKind.DisabledCannotStart, target.ServiceName));
+ }
+
+ if (target.Status is EntryStatus.Paused or EntryStatus.PausePending)
+ {
+ warnings.Add(new PlanWarning(PlanWarningKind.PausedCannotStart, target.ServiceName));
+ }
+ }
+}
diff --git a/src/Bws.Core/Planning/StepCeiling.cs b/src/Bws.Core/Planning/StepCeiling.cs
index 069bc2e..f18f58b 100644
--- a/src/Bws.Core/Planning/StepCeiling.cs
+++ b/src/Bws.Core/Planning/StepCeiling.cs
@@ -30,10 +30,15 @@ public static class StepCeiling
{
///
/// A minute, which is what both interfaces use when nobody says otherwise - `E1` of the
- /// specification uses the number in its own example. It is a cap on the watching rather than a
- /// deadline: an entry that keeps reporting progress is given the time it asks for, and this
- /// only stops a plan sitting on a screen forever when the entry never finishes what it keeps
- /// saying it is doing.
+ /// specification uses the number in its own example.
+ ///
+ /// A minute WITHOUT PROGRESS since 2026-09-30, and this sentence said so before the code did.
+ /// It read "an entry that keeps reporting progress is given the time it asks for" while the runner
+ /// gave up at a minute whatever the entry reported - the stability report caught the two
+ /// disagreeing (W-1), and a restart of a service taking seventy seconds to stop left it stopped.
+ /// The owner's decision of 2026-09-29 made the code match the sentence: the minute starts again
+ /// every time the entry's check point rises or its state changes. The price is that an entry
+ /// reporting progress forever is watched forever - PlanRunner.Run says where the way out is.
///
public static readonly TimeSpan Default = TimeSpan.FromSeconds(60);
diff --git a/src/Bws.Core/WindowsScmControl.cs b/src/Bws.Core/WindowsScmControl.cs
index 42d67a3..de2e1bf 100644
--- a/src/Bws.Core/WindowsScmControl.cs
+++ b/src/Bws.Core/WindowsScmControl.cs
@@ -357,7 +357,9 @@ private static unsafe ControlAnswer ReadProgress(SafeHandle handle)
ManagerTerms.Status(status.dwCurrentState),
status.dwCheckPoint,
TimeSpan.FromMilliseconds(status.dwWaitHint),
- status.dwProcessId));
+ status.dwProcessId,
+ status.dwWin32ExitCode,
+ status.dwServiceSpecificExitCode));
}
}
diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json
index 288250d..174d03b 100644
--- a/src/Bws.Gui/Resources/gui.en.json
+++ b/src/Bws.Gui/Resources/gui.en.json
@@ -228,7 +228,7 @@
"gui.plan.blocked.nothingToRun": "Nothing here can be carried out. What is in the way is listed above.",
"gui.plan.notice.notYet": "Nothing has been done yet.",
"gui.plan.notice.because": "{0} {1}",
- "gui.plan.because.timedOut": "The stop gave up after {0} s.",
+ "gui.plan.because.timedOut": "The stop was given up on once the entry stopped making progress.",
"gui.plan.because.refused": "The stop was refused.",
"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.",
@@ -274,6 +274,8 @@
"gui.plan.warning.alreadyThere": "{0} is already in that state, so nothing would change.",
"gui.plan.warning.keepsRunning": "{0} is running, and a startup type does not stop it. It keeps running until it is stopped or the machine restarts.",
"gui.plan.warning.startsAtNextBoot": "{0} is stopped, and a startup type does not start it. It starts at the next restart of the machine.",
+ "gui.plan.warning.disabledCannotStart": "{0} is disabled, and Windows refuses to start a disabled entry. Change its startup type first.",
+ "gui.plan.warning.pausedCannotStart": "{0} is paused, and a start does not resume a paused service - Windows will refuse it.",
"gui.plan.problems": "Not included, and why",
"gui.plan.problem.unknownService.one": "There is no service called {0} any more.",
@@ -330,13 +332,14 @@
"gui.plan.interrupt": "Interrupt",
"gui.plan.interrupt.hint": "Stop before the next step. Anything already taken down is still put back.",
"gui.plan.progress": "Step {0} of {1}: {2} {3}",
- "gui.plan.progress.waiting": "{0} - {1} s of {2}",
+ "gui.plan.progress.waiting": "{0} - {1} s so far, given up on after {2} s with no progress",
"gui.plan.waiting.label": "Wait up to",
- "gui.plan.waiting.unit": "seconds for each step",
- "gui.plan.waiting.hint": "How long one step is watched for before the run gives up on it and says where the entry was left. It caps the watching, not the manager - a service that reports its own wait hint usually decides first. Sixty seconds is what the terminal uses when nobody says otherwise.",
+ "gui.plan.waiting.unit": "seconds without progress",
+ "gui.plan.waiting.hint": "How long one step may go without progress before the run gives up on it and says where the entry was left. A step that keeps making progress is watched for as long as it takes, and a service's own wait hint usually decides first. Sixty seconds is what the terminal uses when nobody says otherwise.",
"gui.plan.waiting.problem": "Type a whole number of seconds, at least 1.",
"gui.plan.failures": "What did not work",
"gui.plan.failure.refused": "{0} would not {1}: {2}",
+ "gui.plan.failure.stoppedWhileStarting": "{0} did not start - it stopped again with exit code {1}: {2}",
"gui.plan.failure.timedOut": "{0} did not finish the {1} while we watched. It may arrive by itself.",
"gui.plan.failure.timedOut.process": "{0} did not finish the {1} while we watched. It may arrive by itself. Process {2} is still holding it.",
"gui.plan.failure.refusedStartType": "The startup type of {0} could not be changed: {1}",
diff --git a/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs b/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs
new file mode 100644
index 0000000..fb9b2f6
--- /dev/null
+++ b/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs
@@ -0,0 +1,112 @@
+using Bws.Core.Planning;
+
+namespace Bws.Gui.ViewModels;
+
+///
+/// The half of that words what a plan WARNS about.
+///
+/// Its own file since 2026-09-30, and the size ratchet is what asked - the two warnings of the
+/// stability report's package C took PlanWords.cs to 204 lines of code, over the line where a file
+/// counts as close to the ceiling. The seam is the same one the terminal's PlanText was cut along the
+/// same day: every kind of warning arrives here, one arm each, and nothing else grows when one does.
+///
+internal static partial class PlanWords
+{
+ ///
+ /// A warning in words. The kinds come from the core and the sentences belong here.
+ ///
+ /// Written out rather than composed from the name of the value, for the reason the command
+ /// line gives about the same six: flattening a name to lower case works for as long as every one
+ /// is a single word and then quietly asks for a key nobody wrote.
+ ///
+ /// TWO KEYS APIECE WHEREVER A SENTENCE COUNTS SOMETHING OR POINTS AT A GROUP, ADDED
+ /// 2026-08-19. The command line has had exactly this pair since it learned to warn, and its
+ /// own comment says why - a warning reading "1 other entries" spends the trust the warning
+ /// needs. These sentences were written later, from the same facts, and arrived with the plural
+ /// half only. Nothing went red, because no guard in this project reads prose.
+ ///
+ /// The pair is spelled out per branch rather than through a helper that appends ".one" or
+ /// ".many", which is what the command line does. That helper cannot live here: a key built
+ /// as an expression is invisible to TextKeyGuards, so both halves would be reported as text no
+ /// screen ever shows, and the missing one would render on screen as its own key. The head of
+ /// this file carries the same lesson from Doing, one switch earlier.
+ ///
+ internal static string Describe(PlanWarning warning) => warning.Kind switch
+ {
+ PlanWarningKind.Cascade => warning.Related.Count == 1
+ ? Texts.Of(
+ "gui.plan.warning.cascade.one", warning.ServiceName, warning.Related.Count, Listed(warning.Related))
+ : Texts.Of(
+ "gui.plan.warning.cascade.many", warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
+
+ PlanWarningKind.DependentsInTheWay => warning.Related.Count == 1
+ ? Texts.Of("gui.plan.warning.inTheWay.one", warning.ServiceName, Listed(warning.Related))
+ : Texts.Of("gui.plan.warning.inTheWay.many", warning.ServiceName, Listed(warning.Related)),
+
+ PlanWarningKind.SharedProcess => warning.Related.Count == 1
+ ? Texts.Of("gui.plan.warning.sharedProcess.one", warning.ServiceName, Listed(warning.Related))
+ : Texts.Of("gui.plan.warning.sharedProcess.many", warning.ServiceName, Listed(warning.Related)),
+
+ PlanWarningKind.ReturnsAfterReboot => Texts.Of("gui.plan.warning.returnsAfterReboot", warning.ServiceName),
+
+ PlanWarningKind.CascadeUnreadable => Texts.Of("gui.plan.warning.cascadeUnreadable", warning.ServiceName),
+
+ // THE LITERAL SITS INSIDE Texts.Of RATHER THAN IN A TERNARY HANDED TO IT, and the first
+ // attempt did the second - TextKeyGuards found both halves and was right to. Its patterns
+ // read the argument of a call, so a key chosen one line earlier is a key nobody can find by
+ // searching for it, which is the same failure that file records against a key assembled at
+ // run time. The five arms above are all written this way and now so is this one.
+ PlanWarningKind.DoesNotAcceptStop => warning.Related.Count == 1
+ ? Texts.Of(
+ "gui.plan.warning.doesNotAcceptStop.one",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related))
+ : Texts.Of(
+ "gui.plan.warning.doesNotAcceptStop.many",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
+
+ PlanWarningKind.TerminationTakesWithIt => warning.Related.Count == 1
+ ? Texts.Of(
+ "gui.plan.warning.takesWithIt.one",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related))
+ : Texts.Of(
+ "gui.plan.warning.takesWithIt.many",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
+
+ PlanWarningKind.CriticalService => warning.Related.Count == 1
+ ? Texts.Of(
+ "gui.plan.warning.critical.one",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related))
+ : Texts.Of(
+ "gui.plan.warning.critical.many",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
+
+ // SAME NAMES, DIFFERENT WHEN - see the terminal's own arm for the whole of the argument.
+ PlanWarningKind.CriticalStartType => warning.Related.Count == 1
+ ? Texts.Of(
+ "gui.plan.warning.criticalStartType.one",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related))
+ : Texts.Of(
+ "gui.plan.warning.criticalStartType.many",
+ warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
+
+ PlanWarningKind.AlreadyThere => Texts.Of("gui.plan.warning.alreadyThere", warning.ServiceName),
+
+ // The offer to stop it too is not in this sentence - it stands under it as a button, which
+ // PlanWarningLine decides. The terminal's sentence names --stop instead, having no button.
+ PlanWarningKind.KeepsRunning => Texts.Of("gui.plan.warning.keepsRunning", warning.ServiceName),
+
+ PlanWarningKind.StartsAtNextBoot => Texts.Of("gui.plan.warning.startsAtNextBoot", warning.ServiceName),
+
+ PlanWarningKind.DisabledCannotStart => Texts.Of("gui.plan.warning.disabledCannotStart", warning.ServiceName),
+
+ PlanWarningKind.PausedCannotStart => Texts.Of("gui.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" -
+ // a warning added without a sentence would have said something confident and wrong about a
+ // machine rather than nothing at all. The terminal's own switch had the same shape and was
+ // changed the same day.
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(warning), warning.Kind, EquivalentCommand.Unhandled)
+ };
+}
diff --git a/src/Bws.Gui/ViewModels/PlanWords.cs b/src/Bws.Gui/ViewModels/PlanWords.cs
index 5f2ef82..4b4e70d 100644
--- a/src/Bws.Gui/ViewModels/PlanWords.cs
+++ b/src/Bws.Gui/ViewModels/PlanWords.cs
@@ -21,7 +21,7 @@ namespace Bws.Gui.ViewModels;
/// call around it produces strings that never reach a screen and nothing goes red. This project has
/// the lesson from ListState.Say and walked into it again on 2026-08-18.
///
-internal static class PlanWords
+internal static partial class PlanWords
{
///
/// The verb as a person reads it, with the key INSIDE each call.
@@ -65,100 +65,6 @@ internal static class PlanWords
_ => Texts.Of("gui.plan.reason.restore")
};
- ///
- /// A warning in words. The kinds come from the core and the sentences belong here.
- ///
- /// Written out rather than composed from the name of the value, for the reason the command
- /// line gives about the same six: flattening a name to lower case works for as long as every one
- /// is a single word and then quietly asks for a key nobody wrote.
- ///
- /// TWO KEYS APIECE WHEREVER A SENTENCE COUNTS SOMETHING OR POINTS AT A GROUP, ADDED
- /// 2026-08-19. The command line has had exactly this pair since it learned to warn, and its
- /// own comment says why - a warning reading "1 other entries" spends the trust the warning
- /// needs. These sentences were written later, from the same facts, and arrived with the plural
- /// half only. Nothing went red, because no guard in this project reads prose.
- ///
- /// The pair is spelled out per branch rather than through a helper that appends ".one" or
- /// ".many", which is what the command line does. That helper cannot live here: a key built
- /// as an expression is invisible to TextKeyGuards, so both halves would be reported as text no
- /// screen ever shows, and the missing one would render on screen as its own key. The head of
- /// this file carries the same lesson from Doing, one switch earlier.
- ///
- internal static string Describe(PlanWarning warning) => warning.Kind switch
- {
- PlanWarningKind.Cascade => warning.Related.Count == 1
- ? Texts.Of(
- "gui.plan.warning.cascade.one", warning.ServiceName, warning.Related.Count, Listed(warning.Related))
- : Texts.Of(
- "gui.plan.warning.cascade.many", warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
-
- PlanWarningKind.DependentsInTheWay => warning.Related.Count == 1
- ? Texts.Of("gui.plan.warning.inTheWay.one", warning.ServiceName, Listed(warning.Related))
- : Texts.Of("gui.plan.warning.inTheWay.many", warning.ServiceName, Listed(warning.Related)),
-
- PlanWarningKind.SharedProcess => warning.Related.Count == 1
- ? Texts.Of("gui.plan.warning.sharedProcess.one", warning.ServiceName, Listed(warning.Related))
- : Texts.Of("gui.plan.warning.sharedProcess.many", warning.ServiceName, Listed(warning.Related)),
-
- PlanWarningKind.ReturnsAfterReboot => Texts.Of("gui.plan.warning.returnsAfterReboot", warning.ServiceName),
-
- PlanWarningKind.CascadeUnreadable => Texts.Of("gui.plan.warning.cascadeUnreadable", warning.ServiceName),
-
- // THE LITERAL SITS INSIDE Texts.Of RATHER THAN IN A TERNARY HANDED TO IT, and the first
- // attempt did the second - TextKeyGuards found both halves and was right to. Its patterns
- // read the argument of a call, so a key chosen one line earlier is a key nobody can find by
- // searching for it, which is the same failure that file records against a key assembled at
- // run time. The five arms above are all written this way and now so is this one.
- PlanWarningKind.DoesNotAcceptStop => warning.Related.Count == 1
- ? Texts.Of(
- "gui.plan.warning.doesNotAcceptStop.one",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related))
- : Texts.Of(
- "gui.plan.warning.doesNotAcceptStop.many",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
-
- PlanWarningKind.TerminationTakesWithIt => warning.Related.Count == 1
- ? Texts.Of(
- "gui.plan.warning.takesWithIt.one",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related))
- : Texts.Of(
- "gui.plan.warning.takesWithIt.many",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
-
- PlanWarningKind.CriticalService => warning.Related.Count == 1
- ? Texts.Of(
- "gui.plan.warning.critical.one",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related))
- : Texts.Of(
- "gui.plan.warning.critical.many",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
-
- // SAME NAMES, DIFFERENT WHEN - see the terminal's own arm for the whole of the argument.
- PlanWarningKind.CriticalStartType => warning.Related.Count == 1
- ? Texts.Of(
- "gui.plan.warning.criticalStartType.one",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related))
- : Texts.Of(
- "gui.plan.warning.criticalStartType.many",
- warning.ServiceName, warning.Related.Count, Listed(warning.Related)),
-
- PlanWarningKind.AlreadyThere => Texts.Of("gui.plan.warning.alreadyThere", warning.ServiceName),
-
- // The offer to stop it too is not in this sentence - it stands under it as a button, which
- // PlanWarningLine decides. The terminal's sentence names --stop instead, having no button.
- PlanWarningKind.KeepsRunning => Texts.Of("gui.plan.warning.keepsRunning", warning.ServiceName),
-
- PlanWarningKind.StartsAtNextBoot => Texts.Of("gui.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" -
- // a warning added without a sentence would have said something confident and wrong about a
- // machine rather than nothing at all. The terminal's own switch had the same shape and was
- // changed the same day.
- _ => throw new ArgumentOutOfRangeException(
- nameof(warning), warning.Kind, EquivalentCommand.Unhandled)
- };
-
///
/// Warnings in words, with the two kinds that name entries this machine does not work without
/// gathered into ONE sentence each, naming every such entry once.
@@ -389,13 +295,24 @@ internal static string Describe(StepResult result) =>
result.Error ?? string.Empty)
: Ordinary(result);
- private static string Ordinary(StepResult result) => result.Outcome == StepOutcome.TimedOut
- ? TimedOut(result)
- : Texts.Of(
+ private static string Ordinary(StepResult result) => result switch
+ {
+ { Outcome: StepOutcome.TimedOut } => TimedOut(result),
+
+ // NOT "WOULD NOT START", because nothing refused it (stability report W-7, 2026-09-30). The
+ // manager took the start and the service stopped again, and the number is its own exit code.
+ { StoppedWhileStarting: true } => Texts.Of(
+ "gui.plan.failure.stoppedWhileStarting",
+ result.Step.ServiceName,
+ result.ErrorCode,
+ result.Error ?? string.Empty),
+
+ _ => Texts.Of(
"gui.plan.failure.refused",
result.Step.ServiceName,
Word(result.Step.Operation),
- result.Error ?? string.Empty);
+ result.Error ?? string.Empty)
+ };
///
/// A step that was watched and did not arrive, naming the process still holding the entry.
@@ -453,18 +370,14 @@ private static string TimedOut(StepResult result) => result.ProcessId.IsPresent
/// 2025 has been measured here at 30 375 - 30 450 ms, so the half minute this is about is not
/// hypothetical.
///
- /// Both numbers, because one of them alone is the wrong sentence. Elapsed on its own
- /// says how long somebody has been waiting and not whether waiting is nearly over. The ceiling
- /// on its own is the thing they could already have read off the box. Together they say the one
- /// thing worth knowing at that moment: whether this is about to be given up on.
+ /// Both numbers, and since 2026-09-30 the second one says what it counts. Elapsed on its
+ /// own says how long somebody has been waiting and not what would end it. The line read "70 s of
+ /// 60" until the limit started counting time WITHOUT PROGRESS (stability report W-1), and from
+ /// that day seventy of sixty is an ordinary step that kept moving - the words have to say the
+ /// limit is about progress, or the line reads as a counter that overran.
///
/// Whole seconds, rounded down. A tenth of a second changing under somebody's eye is
/// motion carrying no information, and this line sits under a list they are trying to read.
- ///
- /// Past the ceiling is a real state rather than an impossible one. The ceiling caps our
- /// watching, not the manager's answering - PlanRunner says so - so a step can sit at seventy of
- /// sixty while the entry's own wait hint is still being honoured. The words have to survive
- /// that rather than pretend it cannot happen.
///
internal static string StillWaiting(string step, TimeSpan waited, int ceiling)
{
diff --git a/src/Bws.Gui/ViewModels/Planned.Forcing.cs b/src/Bws.Gui/ViewModels/Planned.Forcing.cs
index 4b04054..190d19c 100644
--- a/src/Bws.Gui/ViewModels/Planned.Forcing.cs
+++ b/src/Bws.Gui/ViewModels/Planned.Forcing.cs
@@ -318,7 +318,7 @@ or PlanWarningKind.CriticalService
return null;
}
- return new Escalation(kind, result.Step.ServiceName, Label(kind), Because(run, result));
+ return new Escalation(kind, result.Step.ServiceName, Label(kind), Because(result));
}
///
@@ -348,20 +348,21 @@ private static string Label(ActionKind kind) => kind == ActionKind.ForceStop
: Texts.Of("gui.plan.offer.forceRestart");
///
- /// Why the next sheet is open, in one sentence, read off the run rather than assumed.
+ /// Why the next sheet is open, in one sentence, read off the step rather than assumed.
///
- /// The number of seconds comes from the run's own ceiling and is never written down
- /// here. The window pins its ceiling to the command line's sixty seconds so that "it worked
- /// from the terminal" cannot be a true sentence about the same entry - and a wording that
- /// spelled sixty out would keep saying it after somebody moved that pin.
+ /// NO NUMBER OF SECONDS SINCE 2026-09-30, AND THE ONE THAT STOOD HERE WAS OFTEN FALSE. The
+ /// sentence said "gave up after" the run's ceiling, while a step also gives up when the entry
+ /// breaks its own promise - after two seconds, say - and from that day the ceiling counts time
+ /// without progress, so a stop can give up after three minutes. What is true in every one of those
+ /// cases is that the entry stopped making progress, and that is what it says now.
///
/// Two sentences, because giving up and being refused are different things. The manager
/// took the first request and we stopped watching - it declined the second outright. Telling
/// somebody the first failed would be a claim about something nobody saw, which is the
/// distinction draws and this carries onto a screen.
///
- private static string Because(PlanRun run, StepResult result) =>
+ private static string Because(StepResult result) =>
result.Outcome == StepOutcome.TimedOut
- ? Texts.Of("gui.plan.because.timedOut", (int)run.Ceiling.TotalSeconds)
+ ? Texts.Of("gui.plan.because.timedOut")
: Texts.Of("gui.plan.because.refused");
}
diff --git a/tests/Bws.Cli.Tests/StartFellOverSentenceTests.cs b/tests/Bws.Cli.Tests/StartFellOverSentenceTests.cs
new file mode 100644
index 0000000..8b746fd
--- /dev/null
+++ b/tests/Bws.Cli.Tests/StartFellOverSentenceTests.cs
@@ -0,0 +1,44 @@
+using Bws.Core;
+using Bws.Core.Planning;
+
+namespace Bws.Cli.Tests;
+
+///
+/// The terminal's line for a start the manager took and the service did not survive.
+///
+/// Written 2026-09-30, stability report W-7. Every failed step used to read "refused: ...", and
+/// this one was not refused by anybody - the service stopped again, and the number is its own exit code.
+///
+public sealed class StartFellOverSentenceTests
+{
+ [Fact]
+ public void A_service_that_stopped_while_starting_is_not_called_refused()
+ {
+ var said = PlanText.Describe(Result(stoppedWhileStarting: true));
+
+ Assert.Equal(Texts.Of("cli.run.outcome.stoppedWhileStarting", Words, 1064, "240 ms"), said);
+ }
+
+ [Fact]
+ public void A_start_the_manager_refused_still_reads_as_a_refusal()
+ {
+ var said = PlanText.Describe(Result(stoppedWhileStarting: false));
+
+ Assert.Equal(Texts.Of("cli.run.outcome.failed", Words, 1064), said);
+ }
+
+ private const string Words = "An exception occurred in the service when handling the control request.";
+
+ private static StepResult Result(bool stoppedWhileStarting) => new()
+ {
+ Step = new PlanStep("Spooler", "Print Spooler", StepOperation.Start, StepReason.Requested),
+ Outcome = StepOutcome.Failed,
+ SkippedBecause = null,
+ Status = EntryStatus.Stopped,
+ ProcessId = Reading.Absent(),
+ ErrorCode = 1064,
+ Error = Words,
+ Milliseconds = 240,
+ StoppedWhileStarting = stoppedWhileStarting
+ };
+}
diff --git a/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs b/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs
index ce0e001..543fc13 100644
--- a/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs
+++ b/tests/Bws.Core.Tests/Fakes/FakeScmControl.cs
@@ -175,6 +175,41 @@ internal FakeScmControl Arriving(string serviceName, TimeSpan after, ServiceProg
return this;
}
+ ///
+ /// An entry already on its way somewhere when the run begins - somebody else asked - that gets
+ /// there from now, and until then says . Where
+ /// it is going follows from the pending state it is in: stopping ends in Stopped, anything else in
+ /// Running.
+ ///
+ /// Added 2026-09-30 for stability report W-1 and W-7. Until then every entry here moved only
+ /// when this tool asked it to, so the one shape the report was about - a step meeting an entry
+ /// still stopping - could not be written down at all.
+ ///
+ internal FakeScmControl OnItsWay(string serviceName, TimeSpan after, ServiceProgress meanwhile)
+ {
+ var ruler = clock
+ ?? throw new InvalidOperationException("An entry on its way needs the clock the runner is given.");
+
+ var entry = Entry(serviceName);
+ entry.Arrival = new Arrival(ruler, after, meanwhile);
+ entry.Moving = true;
+ entry.AskedAt = ruler.Elapsed;
+ entry.Heading = meanwhile.Status == EntryStatus.StopPending ? EntryStatus.Stopped : EntryStatus.Running;
+ entry.Status = meanwhile.Status;
+ return this;
+ }
+
+ ///
+ /// An entry whose manager sits on every request for before answering it -
+ /// the shape measured on Windows Server 2025, where one start took half a minute to come back.
+ ///
+ internal FakeScmControl SlowToAnswer(string serviceName, TimeSpan by)
+ {
+ _ = clock ?? throw new InvalidOperationException("A slow answer needs the clock the runner is given.");
+ Entry(serviceName).AnswersAfter = by;
+ return this;
+ }
+
///
/// How many times anybody asked where an entry is, every entry counted.
///
@@ -205,16 +240,17 @@ public ControlAnswer Request(string serviceName, StepOperation operation)
var entry = Entry(serviceName);
- if (entry.RequestRefusedWith is { } refused)
+ if (entry.AnswersAfter is { } slow)
{
- if (entry.BecomesOnRefusal is { } becomes)
- {
- entry.Status = becomes;
- }
+ clock!.Wait(slow);
+ }
- return ControlAnswer.Refused(refused, $"refused with {refused}");
+ if (Refusal(entry, operation) is { } refused)
+ {
+ return refused;
}
+ entry.AskedAgain();
entry.Moving = true;
if (entry.Arrival is { } arrival)
@@ -235,6 +271,35 @@ public ControlAnswer Request(string serviceName, StepOperation operation)
return ControlAnswer.Done();
}
+ ///
+ /// What the manager says no to before anything moves: an entry told to refuse, and - since
+ /// 2026-09-30 - an entry still on its way somewhere, as a real manager refuses a stop to an entry
+ /// already stopping and a start to one still stopping. Before that day nothing here was ever on its
+ /// way when it was asked. Its own method because the shape guard counts the forks of a test method.
+ ///
+ private static ControlAnswer? Refusal(Behaviour entry, StepOperation operation)
+ {
+ if (entry.RequestRefusedWith is { } refused)
+ {
+ if (entry.BecomesOnRefusal is { } becomes)
+ {
+ entry.Status = becomes;
+ }
+
+ return ControlAnswer.Refused(refused, $"refused with {refused}");
+ }
+
+ if (entry.Moving
+ && entry.Arrival is { } onItsWay
+ && onItsWay.Clock.Elapsed - entry.AskedAt < onItsWay.After)
+ {
+ var code = operation == StepOperation.Stop ? CannotAcceptControl : AlreadyRunning;
+ return ControlAnswer.Refused(code, $"refused with {code}");
+ }
+
+ return null;
+ }
+
public ControlAnswer Read(string serviceName)
{
Reads++;
@@ -286,6 +351,12 @@ public ControlAnswer Read(string serviceName)
///
internal const uint FakeProcess = 4812;
+ /// ERROR_SERVICE_CANNOT_ACCEPT_CTRL - what a stop to an entry already stopping gets.
+ internal const int CannotAcceptControl = 1061;
+
+ /// ERROR_SERVICE_ALREADY_RUNNING - what a start to an entry that is not stopped gets.
+ internal const int AlreadyRunning = 1056;
+
private static uint Held(Behaviour entry) =>
entry.Status == EntryStatus.Stopped ? 0 : (uint)entry.ProcessId;
@@ -311,6 +382,21 @@ private sealed class Behaviour
internal int? ConfigureRefusedWith { get; set; }
+ internal TimeSpan? AnswersAfter { get; set; }
+
+ ///
+ /// A SECOND REQUEST AFTER THE SCRIPT HAS PLAYED OUT starts from where the entry stands, rather
+ /// than handing out the last reading of the FIRST request's journey for ever - so a restart of
+ /// an entry scripted to stop slowly can be put back. Since 2026-09-30.
+ ///
+ internal void AskedAgain()
+ {
+ if (Moving && AfterRequest is { Count: 1 })
+ {
+ AfterRequest = null;
+ }
+ }
+
internal EntryStatus Status { get; set; } = EntryStatus.Running;
internal Queue? AfterRequest { get; set; }
diff --git a/tests/Bws.Core.Tests/PlanRunCeilingTests.cs b/tests/Bws.Core.Tests/PlanRunCeilingTests.cs
index b9bb9b4..d673d61 100644
--- a/tests/Bws.Core.Tests/PlanRunCeilingTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunCeilingTests.cs
@@ -26,8 +26,8 @@ public sealed class PlanRunCeilingTests
[Fact]
public void A_step_that_ran_past_the_ceiling_is_named()
{
- // The measured case, in the numbers it actually had.
- var run = RunWith(Ceiling(1), Step(StepOutcome.Failed, milliseconds: 30_450));
+ // The measured case, in the numbers it actually had - all of it inside the start call.
+ var run = RunWith(Ceiling(1), Step(StepOutcome.Failed, milliseconds: 30_450, answered: 30_450));
var outran = Assert.Single(run.OutranTheCeiling);
Assert.Equal(30_450, outran.Milliseconds);
@@ -59,11 +59,23 @@ public void A_step_that_succeeded_late_is_named_too()
// Not only refusals. If the manager held a stop past the ceiling and it then worked,
// the command still outran what was asked for, and the person waiting deserves the
// same sentence. Built rather than captured - no machine has shown this yet.
- var run = RunWith(Ceiling(1), Step(StepOutcome.Succeeded, milliseconds: 4_000));
+ var run = RunWith(Ceiling(1), Step(StepOutcome.Succeeded, milliseconds: 4_000, answered: 3_900));
Assert.Single(run.OutranTheCeiling);
}
+ [Fact]
+ public void A_step_that_kept_making_progress_past_the_ceiling_is_not_named()
+ {
+ // THE CASE THAT MOVED THIS ONTO THE ANSWER, 2026-09-30. The ceiling counts time without
+ // progress from that day, so a stop reporting progress for ninety seconds under a limit of
+ // sixty is the limit working - and read off the whole step it would have told somebody the
+ // manager took ninety seconds to answer, which it did not.
+ var run = RunWith(Ceiling(60), Step(StepOutcome.Succeeded, milliseconds: 90_000, answered: 5));
+
+ Assert.Empty(run.OutranTheCeiling);
+ }
+
[Fact]
public void Every_step_that_outran_it_is_named_rather_than_the_first()
{
@@ -71,17 +83,18 @@ public void Every_step_that_outran_it_is_named_rather_than_the_first()
// somebody looking for a single slow entry that is not the whole story.
var run = RunWith(
Ceiling(1),
- Step(StepOutcome.Failed, milliseconds: 30_450),
+ Step(StepOutcome.Failed, milliseconds: 30_450, answered: 30_450),
Step(StepOutcome.Succeeded, milliseconds: 200),
- Step(StepOutcome.Failed, milliseconds: 30_100));
+ Step(StepOutcome.Failed, milliseconds: 30_100, answered: 30_100));
Assert.Equal(2, run.OutranTheCeiling.Count);
}
private static TimeSpan Ceiling(int seconds) => TimeSpan.FromSeconds(seconds);
- private static StepResult Step(StepOutcome outcome, long milliseconds) => new()
+ private static StepResult Step(StepOutcome outcome, long milliseconds, long answered = 0) => new()
{
+ Answered = answered,
Step = new PlanStep("Any", "Any", StepOperation.Start, StepReason.Requested),
Outcome = outcome,
SkippedBecause = null,
diff --git a/tests/Bws.Core.Tests/PlanRunClockTests.cs b/tests/Bws.Core.Tests/PlanRunClockTests.cs
index 1804e65..4bc96f4 100644
--- a/tests/Bws.Core.Tests/PlanRunClockTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunClockTests.cs
@@ -22,14 +22,22 @@ public sealed class PlanRunClockTests
[Fact]
public void A_clock_jumping_forward_does_not_give_up_on_a_service_that_is_still_working()
{
- // The entry keeps its side of the Win32 promise: the check point rises every time it is
- // asked, so it has earned every second it is taking. An hour arrives on the wall clock
- // while it does, which is what resuming a suspended machine looks like from in here.
+ // The entry keeps its side of the Win32 promise: the check point rises well inside the five
+ // seconds it asked for, so it has earned every second it is taking. An hour arrives on the
+ // wall clock while it does, which is what resuming a suspended machine looks like from in here.
+ //
+ // NOT A RISE AT EVERY LOOK, SINCE 2026-09-30, and that is the point of the repeats. From that
+ // day the limit counts from the last progress, and both ends of that comparison would come from
+ // the same clock - so an entry moving at every look gave a wall clock nothing to break, and
+ // the mutation reading the wall clock survived. Two looks with no rise between them, an hour
+ // apart on the wall and milliseconds apart on the ruler, are where the fault lives.
var control = new FakeScmControl()
.At("Spooler", EntryStatus.Running)
.Reaching(
"Spooler",
new ServiceProgress(EntryStatus.StopPending, 1, TimeSpan.FromSeconds(5), ProcessId: 4812),
+ new ServiceProgress(EntryStatus.StopPending, 1, TimeSpan.FromSeconds(5), ProcessId: 4812),
+ new ServiceProgress(EntryStatus.StopPending, 2, TimeSpan.FromSeconds(5), ProcessId: 4812),
new ServiceProgress(EntryStatus.StopPending, 2, TimeSpan.FromSeconds(5), ProcessId: 4812),
new ServiceProgress(EntryStatus.Stopped, 0, TimeSpan.Zero, ProcessId: 0));
diff --git a/tests/Bws.Core.Tests/PlanRunHaltTests.cs b/tests/Bws.Core.Tests/PlanRunHaltTests.cs
new file mode 100644
index 0000000..d938017
--- /dev/null
+++ b/tests/Bws.Core.Tests/PlanRunHaltTests.cs
@@ -0,0 +1,126 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+namespace Bws.Core.Tests;
+
+///
+/// What the two ways of stopping a run do to a step that is WAITING.
+///
+/// Written 2026-09-30, stability report W-11, on the owner's decision of 2026-09-29. Until that
+/// day both were checked only between steps, so the terminal's second Ctrl+C - "stop altogether" -
+/// waited out the step in flight, which from that day can be as long as the entry keeps moving. The
+/// rule now: before anything is asked, a waiting step stops exactly as a step not yet reached would,
+/// and after the manager was asked only abandonment ends the watching.
+///
+public sealed class PlanRunHaltTests
+{
+ private const string Name = "Spooler";
+
+ [Fact]
+ public void Abandoning_during_the_watch_ends_it_rather_than_waiting_out_the_limit()
+ {
+ using var abandonment = new CancellationTokenSource();
+ var clock = new StoppingClock(TimeSpan.FromSeconds(2), abandonment);
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Reaching(Name, PlanRunProgressTests.Pending(0, TimeSpan.Zero));
+
+ var run = new PlanRunner(control, clock)
+ .Run(PlanRunProgressTests.Stopping(), TimeSpan.FromSeconds(60), abandonment: abandonment.Token);
+
+ // The watching ended, the entry may still arrive - which is what the outcome says, on the
+ // owner's decision of 2026-09-30, rather than a new value of it.
+ Assert.Equal(StepOutcome.TimedOut, Assert.Single(run.Results).Outcome);
+ Assert.True(clock.Elapsed < TimeSpan.FromSeconds(3), $"Watched for {clock.Elapsed}.");
+ Assert.True(run.Cancelled);
+ }
+
+ [Fact]
+ public void Abandoning_before_the_request_asks_nothing()
+ {
+ using var abandonment = new CancellationTokenSource();
+ var clock = new StoppingClock(TimeSpan.FromSeconds(2), abandonment);
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromSeconds(90), PlanRunProgressTests.Pending(1, TimeSpan.Zero));
+
+ var run = new PlanRunner(control, clock)
+ .Run(PlanRunProgressTests.Starting(), TimeSpan.FromSeconds(60), abandonment: abandonment.Token);
+
+ Assert.Equal(SkipReason.Cancelled, Assert.Single(run.Results).SkippedBecause);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void Abandoning_while_an_entry_already_on_its_way_is_watched_reports_a_step_never_attempted()
+ {
+ // Nothing was asked of an entry that was stopping by itself, so the step was never attempted -
+ // a timeout here would have the way back count a move this tool never made.
+ using var abandonment = new CancellationTokenSource();
+ var clock = new StoppingClock(TimeSpan.FromSeconds(2), abandonment);
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromSeconds(90), PlanRunProgressTests.Pending(1, TimeSpan.Zero));
+
+ var run = new PlanRunner(control, clock)
+ .Run(PlanRunProgressTests.Stopping(), TimeSpan.FromSeconds(60), abandonment: abandonment.Token);
+
+ Assert.Equal(SkipReason.Cancelled, Assert.Single(run.Results).SkippedBecause);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void An_interruption_skips_a_step_going_forward_that_is_still_waiting_to_be_asked()
+ {
+ using var interruption = new CancellationTokenSource();
+ var clock = new StoppingClock(TimeSpan.FromSeconds(2), interruption);
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromSeconds(90), PlanRunProgressTests.Pending(1, TimeSpan.Zero));
+
+ var run = new PlanRunner(control, clock)
+ .Run(PlanRunProgressTests.Starting(), TimeSpan.FromSeconds(60), interruption.Token);
+
+ Assert.Equal(SkipReason.Cancelled, Assert.Single(run.Results).SkippedBecause);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void An_interruption_does_not_stop_a_step_putting_something_back_from_waiting_to_be_asked()
+ {
+ // The first level promises the steps that give back what earlier ones took, and a start waiting
+ // for the entry to finish stopping is one of them - the restart of the package's own reason,
+ // interrupted while the entry was still stopping.
+ using var interruption = new CancellationTokenSource();
+ var clock = new StoppingClock(TimeSpan.FromSeconds(70), interruption);
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Arriving(Name, TimeSpan.FromSeconds(90), PlanRunProgressTests.Pending(1, TimeSpan.Zero));
+
+ var run = new PlanRunner(control, clock)
+ .Run(PlanRunProgressTests.Restarting(), TimeSpan.FromSeconds(60), interruption.Token);
+
+ Assert.Equal(StepOutcome.Succeeded, run.Results[1].Outcome);
+ Assert.True(run.Cancelled);
+ }
+
+ ///
+ /// Time that only moves when somebody waits, and that pulls one of the run's two levers once it has
+ /// moved far enough - the only way to reach a runner in the middle of a step without a second thread.
+ ///
+ private sealed class StoppingClock(TimeSpan at, CancellationTokenSource lever) : IClock
+ {
+ private readonly FakeClock _time = new();
+
+ public DateTimeOffset Now => _time.Now;
+
+ public TimeSpan Elapsed => _time.Elapsed;
+
+ public void Wait(TimeSpan duration)
+ {
+ _time.Wait(duration);
+
+ if (_time.Elapsed >= at)
+ {
+ lever.Cancel();
+ }
+ }
+ }
+}
diff --git a/tests/Bws.Core.Tests/PlanRunProgressTests.cs b/tests/Bws.Core.Tests/PlanRunProgressTests.cs
new file mode 100644
index 0000000..3ba7b0d
--- /dev/null
+++ b/tests/Bws.Core.Tests/PlanRunProgressTests.cs
@@ -0,0 +1,174 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+namespace Bws.Core.Tests;
+
+///
+/// What the limit of a step counts, and what a step does with an entry already on its way somewhere.
+///
+/// Written 2026-09-30 for the stability report's W-1 and W-7, on the owner's decision of
+/// 2026-09-29. The limit - --timeout, the window's "Wait up to" box - counts time WITHOUT
+/// PROGRESS, so a service stopping honestly for longer than the limit is watched to its end. And a step
+/// meeting an entry still moving waits for it instead of asking, which the manager would refuse. Every
+/// test here was red on the code before that day. The restart one is the reason for the package: it
+/// left the service stopped.
+///
+public sealed class PlanRunProgressTests
+{
+ private const string Name = "Spooler";
+
+ private static readonly TimeSpan Limit = TimeSpan.FromSeconds(60);
+
+ private static readonly ServiceProgress Stopped = new(EntryStatus.Stopped, 0, TimeSpan.Zero, ProcessId: 0);
+
+ [Fact]
+ public void A_stop_that_keeps_making_progress_past_the_limit_is_watched_to_its_end()
+ {
+ // About a hundred seconds of a check point rising, under a limit of sixty. Until 2026-09-30 the
+ // limit was a wall across the whole step and this was given up on at sixty.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Reaching(Name, [.. Rising(400), Stopped]);
+
+ var result = Assert.Single(Run(control, clock, Stopping()).Results);
+
+ Assert.Equal(StepOutcome.Succeeded, result.Outcome);
+ Assert.True(clock.Waited > Limit, $"Waited only {clock.Waited}.");
+ }
+
+ [Fact]
+ public void The_limit_is_counted_from_the_last_progress_rather_than_from_the_start_of_the_step()
+ {
+ // A hundred and twenty rises - the last at about 29 s on the runner's pace of looking - and then
+ // nothing. Given up on a limit after the LAST rise, so at about 89 s, never at 60.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Reaching(Name, [.. Rising(120), Pending(120, TimeSpan.Zero)]);
+
+ var result = Assert.Single(Run(control, clock, Stopping()).Results);
+
+ Assert.Equal(StepOutcome.TimedOut, result.Outcome);
+ Assert.InRange(clock.Waited, TimeSpan.FromSeconds(85), TimeSpan.FromSeconds(95));
+ }
+
+ [Fact]
+ public void A_restart_whose_stop_ran_out_starts_the_entry_once_it_has_finished_stopping()
+ {
+ // THE REASON FOR THE PACKAGE. The stop is given up on at the limit - the entry promised nothing
+ // and sat still - and the entry finishes stopping at ninety seconds, after the run has moved on
+ // to putting it back. Until 2026-09-30 the start went straight to an entry still stopping, the
+ // manager refused it, and the service stayed stopped.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Arriving(Name, TimeSpan.FromSeconds(90), Pending(1, TimeSpan.Zero));
+
+ var run = Run(control, clock, Restarting());
+
+ Assert.Equal(StepOutcome.TimedOut, run.Results[0].Outcome);
+ Assert.Equal(StepOutcome.Succeeded, run.Results[1].Outcome);
+ Assert.Equal(EntryStatus.Running, run.Results[1].Status);
+
+ // Asked twice - the stop, then the start once it could be taken.
+ Assert.Equal([Name, Name], control.Requested);
+ }
+
+ [Fact]
+ public void A_stop_that_meets_an_entry_already_stopping_asks_nothing_and_waits_for_it()
+ {
+ // The manager refuses a stop to an entry already stopping, and until 2026-09-30 that refusal was
+ // the step's result - a failure over an entry on its way to exactly where the step wanted it.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromSeconds(5), Pending(1, TimeSpan.FromSeconds(10)));
+
+ var result = Assert.Single(Run(control, clock, Stopping()).Results);
+
+ Assert.Equal(StepOutcome.Succeeded, result.Outcome);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void A_start_that_meets_an_entry_still_stopping_waits_for_it_to_stop_and_then_asks()
+ {
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromSeconds(5), Pending(1, TimeSpan.FromSeconds(10)));
+
+ var result = Assert.Single(Run(control, clock, Starting()).Results);
+
+ Assert.Equal(StepOutcome.Succeeded, result.Outcome);
+ Assert.Equal([Name], control.Requested);
+ }
+
+ [Fact]
+ public void An_entry_that_never_leaves_the_other_way_is_never_asked()
+ {
+ // Nothing was sent, so the way back must not count it as a move - a failure in our own words
+ // with no number, not a timeout, which the way back reads as "may have moved".
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .OnItsWay(Name, TimeSpan.FromMinutes(10), Pending(1, TimeSpan.Zero));
+
+ var result = Assert.Single(Run(control, clock, Starting()).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.Equal(0, result.ErrorCode);
+ Assert.False(string.IsNullOrWhiteSpace(result.Error));
+ Assert.Equal(EntryStatus.StopPending, result.Status);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void The_time_the_manager_sits_on_a_request_is_what_outruns_the_limit()
+ {
+ // Half a minute inside the start call, as measured on Windows Server 2025, under a limit of one
+ // second. That is named - and the watching after it would not have been.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Stopped)
+ .SlowToAnswer(Name, TimeSpan.FromSeconds(30));
+
+ var run = new PlanRunner(control, clock).Run(Starting(), TimeSpan.FromSeconds(1));
+
+ Assert.Equal(StepOutcome.Succeeded, run.Results[0].Outcome);
+ Assert.Equal(30_000, Assert.Single(run.OutranTheCeiling).Answered);
+ }
+
+ internal static OperationPlan Stopping() =>
+ Planned(ActionKind.Stop, Step(StepOperation.Stop, StepReason.Requested));
+
+ internal static OperationPlan Starting() =>
+ Planned(ActionKind.Start, Step(StepOperation.Start, StepReason.Requested));
+
+ internal static OperationPlan Restarting() => Planned(
+ ActionKind.Restart,
+ Step(StepOperation.Stop, StepReason.Requested),
+ Step(StepOperation.Start, StepReason.Restore));
+
+ /// An entry stopping, with its check point where it is and a wait hint of its own.
+ internal static ServiceProgress Pending(uint checkPoint, TimeSpan hint) =>
+ new(EntryStatus.StopPending, checkPoint, hint, ProcessId: FakeScmControl.FakeProcess);
+
+ private static PlanRun Run(FakeScmControl control, FakeClock clock, OperationPlan plan) =>
+ new PlanRunner(control, clock).Run(plan, Limit);
+
+ ///
+ /// A check point rising once per look and no wait hint - so nothing but the limit can end the watch,
+ /// and every look is progress.
+ ///
+ private static IEnumerable Rising(int count) =>
+ Enumerable.Range(1, count).Select(point => Pending((uint)point, TimeSpan.Zero));
+
+ private static OperationPlan Planned(ActionKind kind, params PlanStep[] steps) => new()
+ {
+ Action = new ServiceAction(kind, Name),
+ Steps = steps,
+ Warnings = [],
+ Problems = []
+ };
+
+ private static PlanStep Step(StepOperation operation, StepReason reason) => new(Name, Name, operation, reason);
+}
diff --git a/tests/Bws.Core.Tests/RefusedStartWarningTests.cs b/tests/Bws.Core.Tests/RefusedStartWarningTests.cs
new file mode 100644
index 0000000..143d693
--- /dev/null
+++ b/tests/Bws.Core.Tests/RefusedStartWarningTests.cs
@@ -0,0 +1,86 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+namespace Bws.Core.Tests;
+
+///
+/// A plan that starts an entry says so when what was read already names the manager's refusal.
+///
+/// Written 2026-09-30, stability report W-7 and W-8, on the owner's decision of that day: a warning,
+/// not a refusal. Measured before the change on this machine: bws start on a disabled entry
+/// gave a plan of one step, no warning and exit code 0. Each test here reads what was READ - an entry
+/// whose start type or state nobody knows is not warned about, because a warning about it would be a
+/// claim.
+///
+public sealed class RefusedStartWarningTests
+{
+ private const string Name = "Spooler";
+
+ [Fact]
+ public void Starting_a_disabled_entry_that_is_stopped_says_Windows_will_refuse_it()
+ {
+ var plan = Build(ActionKind.Start, EntryStatus.Stopped, Reading.Present(StartType.Disabled));
+
+ Assert.Contains(plan.Warnings, warning => warning.Kind == PlanWarningKind.DisabledCannotStart);
+
+ // A warning and not a refusal - the plan is still one step anybody can carry out.
+ Assert.True(plan.IsRunnable);
+ }
+
+ [Fact]
+ public void Starting_a_paused_entry_says_a_start_does_not_resume_it()
+ {
+ var plan = Build(ActionKind.Start, EntryStatus.Paused, Reading.Present(StartType.Manual));
+
+ Assert.Contains(plan.Warnings, warning => warning.Kind == PlanWarningKind.PausedCannotStart);
+ }
+
+ [Theory]
+ [InlineData(EntryStatus.Running)]
+ [InlineData(EntryStatus.StartPending)]
+ [InlineData(EntryStatus.Unknown)]
+ public void A_disabled_entry_that_is_not_stopped_is_not_warned_about(EntryStatus status)
+ {
+ // Running needs no start, starting is waited for rather than asked, and a state nobody read is
+ // not "stopped".
+ var plan = Build(ActionKind.Start, status, Reading.Present(StartType.Disabled));
+
+ Assert.DoesNotContain(plan.Warnings, warning => warning.Kind == PlanWarningKind.DisabledCannotStart);
+ }
+
+ [Fact]
+ public void An_entry_whose_start_type_nobody_read_is_not_called_disabled()
+ {
+ var plan = Build(ActionKind.Start, EntryStatus.Stopped, Reading.NotRead());
+
+ Assert.DoesNotContain(plan.Warnings, warning => warning.Kind == PlanWarningKind.DisabledCannotStart);
+ }
+
+ [Fact]
+ public void Stopping_a_paused_entry_is_not_warned_about()
+ {
+ // A paused service takes a stop, so only a start meets the refusal.
+ var plan = Build(ActionKind.Stop, EntryStatus.Paused, Reading.Present(StartType.Manual));
+
+ Assert.DoesNotContain(plan.Warnings, warning => warning.Kind == PlanWarningKind.PausedCannotStart);
+ }
+
+ private static OperationPlan Build(ActionKind kind, EntryStatus status, Reading startType)
+ {
+ var entries = new List
+ {
+ Entries.Named(Name, "Print Spooler") with
+ {
+ Status = status,
+ StartType = startType,
+ DelayedAuto = Reading.Absent(),
+ ProcessId = status == EntryStatus.Stopped ? Reading.Absent() : Reading.Present(4444)
+ }
+ };
+
+ var catalog = new FakeScmCatalog(entries);
+
+ return new PlanBuilder(catalog.ReadAll(), catalog)
+ .Build(new ServiceAction(kind, Name, IncludeDependents: false));
+ }
+}
diff --git a/tests/Bws.Core.Tests/StartFellOverTests.cs b/tests/Bws.Core.Tests/StartFellOverTests.cs
new file mode 100644
index 0000000..51f8838
--- /dev/null
+++ b/tests/Bws.Core.Tests/StartFellOverTests.cs
@@ -0,0 +1,89 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+namespace Bws.Core.Tests;
+
+///
+/// A start the manager took and the service did not survive.
+///
+/// Written 2026-09-30, stability report W-7. Such a service goes back to Stopped with no wait
+/// hint, and until that day the step watched it for the whole limit and reported that it ran out of
+/// time - a sentence about waiting, a minute late, over a service that had already told the manager
+/// exactly what went wrong. The number it left is its own exit code, and on the owner's decision of
+/// that day it travels as errorCode, with the service's own code in the words only.
+///
+public sealed class StartFellOverTests
+{
+ private const string Name = "Spooler";
+
+ private static readonly ServiceProgress Starting =
+ new(EntryStatus.StartPending, 1, TimeSpan.FromSeconds(2), ProcessId: FakeScmControl.FakeProcess);
+
+ [Fact]
+ public void A_start_that_falls_back_to_stopped_fails_at_once_with_the_service_exit_code()
+ {
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Stopped)
+ .Reaching(Name, Starting, Fell(1064, 0));
+
+ var result = Assert.Single(Run(control, clock).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.True(result.StoppedWhileStarting);
+ Assert.Equal(1064, result.ErrorCode);
+ Assert.Equal(EntryStatus.Stopped, result.Status);
+ Assert.True(clock.Waited < TimeSpan.FromSeconds(1), $"Watched for {clock.Waited} before saying so.");
+ }
+
+ [Fact]
+ public void A_service_specific_code_travels_in_the_words_and_never_as_the_number()
+ {
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Stopped)
+ .Reaching(Name, Starting, Fell(1066, 42));
+
+ var result = Assert.Single(Run(control, clock).Results);
+
+ Assert.Equal(1066, result.ErrorCode);
+ Assert.EndsWith("(42)", result.Error, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void A_start_the_manager_refused_is_not_a_service_that_fell_over()
+ {
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock).At(Name, EntryStatus.Stopped).RefusingRequests(Name, 1058);
+
+ var result = Assert.Single(Run(control, clock).Results);
+
+ Assert.Equal(StepOutcome.Failed, result.Outcome);
+ Assert.False(result.StoppedWhileStarting);
+ }
+
+ [Fact]
+ public void A_stop_that_still_reads_running_just_after_the_request_is_watched_rather_than_failed()
+ {
+ // The mirror is deliberately NOT a failure: a service may take the stop and only report stop
+ // pending a moment later, so Running straight after a stop is ordinary.
+ var clock = new FakeClock();
+ var control = new FakeScmControl(clock)
+ .At(Name, EntryStatus.Running)
+ .Reaching(
+ Name,
+ new ServiceProgress(EntryStatus.Running, 0, TimeSpan.Zero, ProcessId: FakeScmControl.FakeProcess),
+ new ServiceProgress(EntryStatus.Stopped, 0, TimeSpan.Zero, ProcessId: 0));
+
+ var run = new PlanRunner(control, clock).Run(PlanRunProgressTests.Stopping(), TimeSpan.FromSeconds(60));
+ var result = Assert.Single(run.Results);
+
+ Assert.Equal(StepOutcome.Succeeded, result.Outcome);
+ }
+
+ private static ServiceProgress Fell(uint exitCode, uint serviceExitCode) =>
+ new(EntryStatus.Stopped, 0, TimeSpan.Zero, ProcessId: 0, exitCode, serviceExitCode);
+
+ private static PlanRun Run(FakeScmControl control, FakeClock clock) =>
+ new PlanRunner(control, clock).Run(PlanRunProgressTests.Starting(), TimeSpan.FromSeconds(60));
+}
diff --git a/tests/Bws.Gui.Tests/ForcedStopGuards.cs b/tests/Bws.Gui.Tests/ForcedStopGuards.cs
index 0486ebb..f4fc43a 100644
--- a/tests/Bws.Gui.Tests/ForcedStopGuards.cs
+++ b/tests/Bws.Gui.Tests/ForcedStopGuards.cs
@@ -177,10 +177,10 @@ public void The_offer_carries_why_it_is_being_offered()
timedOut.Finished(Ended(timedOut, StepOutcome.TimedOut));
refused.Finished(Ended(refused, StepOutcome.Failed));
- // The number is the run's own ceiling rather than a sixty written down here, which is what
- // keeps this sentence true if the window's ceiling ever moves.
+ // No number since 2026-09-30 - the limit counts time without progress and a step also gives
+ // up on the entry's own promise, so any number of seconds here could be false.
Assert.Equal(
- Bws.Gui.Texts.Of("gui.plan.because.timedOut", 60),
+ Bws.Gui.Texts.Of("gui.plan.because.timedOut"),
Assert.Single(timedOut.Failures).Offer!.Because);
Assert.Equal(
diff --git a/tests/Bws.Gui.Tests/ForcedStopViewGuards.cs b/tests/Bws.Gui.Tests/ForcedStopViewGuards.cs
index 626df28..e6278ae 100644
--- a/tests/Bws.Gui.Tests/ForcedStopViewGuards.cs
+++ b/tests/Bws.Gui.Tests/ForcedStopViewGuards.cs
@@ -97,7 +97,7 @@ public async Task Taking_the_offer_opens_a_new_sheet_holding_a_plan_that_ends_a_
// THE REASON CAME ACROSS, which is the only thing left saying what happened before - the
// sheet that knew it is gone by now.
Assert.StartsWith(
- Bws.Gui.Texts.Of("gui.plan.because.timedOut", 60),
+ Bws.Gui.Texts.Of("gui.plan.because.timedOut"),
WpfHost.On(() => window.PlanPanel.Notice.Text),
StringComparison.Ordinal);
diff --git a/tests/Bws.Gui.Tests/StartFellOverWordsGuards.cs b/tests/Bws.Gui.Tests/StartFellOverWordsGuards.cs
new file mode 100644
index 0000000..672d714
--- /dev/null
+++ b/tests/Bws.Gui.Tests/StartFellOverWordsGuards.cs
@@ -0,0 +1,46 @@
+using Bws.Core;
+using Bws.Core.Planning;
+using Bws.Gui.ViewModels;
+
+namespace Bws.Gui.Tests;
+
+///
+/// The window's sentence for a start the manager took and the service did not survive.
+///
+/// Written 2026-09-30, stability report W-7. The shared sentence says the entry "would not
+/// start", which is a refusal - and nothing refused this one. The service stopped again, and the number
+/// beside it is its own exit code.
+///
+public sealed class StartFellOverWordsGuards
+{
+ [Fact]
+ public void A_service_that_stopped_while_starting_says_so_with_its_exit_code()
+ {
+ var said = PlanWords.Describe(Result(stoppedWhileStarting: true));
+
+ Assert.Equal(Texts.Of("gui.plan.failure.stoppedWhileStarting", "Spooler", 1064, Words), said);
+ }
+
+ [Fact]
+ public void A_start_the_manager_refused_still_reads_as_a_refusal()
+ {
+ var said = PlanWords.Describe(Result(stoppedWhileStarting: false));
+
+ Assert.Equal(Texts.Of("gui.plan.failure.refused", "Spooler", Texts.Of("gui.plan.operation.start"), Words), said);
+ }
+
+ private const string Words = "An exception occurred in the service when handling the control request.";
+
+ private static StepResult Result(bool stoppedWhileStarting) => new()
+ {
+ Step = new PlanStep("Spooler", "Print Spooler", StepOperation.Start, StepReason.Requested),
+ Outcome = StepOutcome.Failed,
+ SkippedBecause = null,
+ Status = EntryStatus.Stopped,
+ ProcessId = Reading.Absent(),
+ ErrorCode = 1064,
+ Error = Words,
+ Milliseconds = 240,
+ StoppedWhileStarting = stoppedWhileStarting
+ };
+}
diff --git a/tests/Bws.Gui.Tests/WaitingGuards.cs b/tests/Bws.Gui.Tests/WaitingGuards.cs
index 755255a..82bd294 100644
--- a/tests/Bws.Gui.Tests/WaitingGuards.cs
+++ b/tests/Bws.Gui.Tests/WaitingGuards.cs
@@ -232,9 +232,9 @@ public void Past_a_second_it_says_how_long_and_out_of_how_long()
///
/// Past the ceiling is a real state rather than an impossible one, and the words have to
- /// survive it rather than pretend it cannot happen. The ceiling caps our watching, not the
- /// manager's answering - an entry reporting its own wait hint is still being honoured while
- /// this reads seventy of sixty.
+ /// survive it rather than pretend it cannot happen. Since 2026-09-30 the ceiling counts time
+ /// without progress, so an entry that keeps moving is still being watched while this reads
+ /// seventy.
///
[Fact]
public void Past_the_ceiling_it_keeps_counting_rather_than_stopping_at_the_number() =>