diff --git a/desktop/src-tauri/src/runtime_stop.rs b/desktop/src-tauri/src/runtime_stop.rs index 664da927067..755dd0a896a 100644 --- a/desktop/src-tauri/src/runtime_stop.rs +++ b/desktop/src-tauri/src/runtime_stop.rs @@ -22,13 +22,50 @@ use tokio::time::{timeout_at, Instant}; pub const SCHEMA: &str = "ocx-stop/1"; const HISTORY_INCOMPLETE_EXIT_CODE: i32 = 79; -/// How long the stop may take. +/// How long the exit or restart drain's stop may take. /// /// The CLI's stop drains in-flight requests, restores client configuration and verifies the Windows /// respawn window, so this is generous on purpose: it bounds a hang, it does not pace a healthy /// stop. Overrunning it is a failure, not a stop, because the caller's next step is to end the app /// or replace the files the runtime is serving out of. -pub const DEADLINE: Duration = Duration::from_secs(30); +/// +/// Windows gets longer. There the CLI stops the service manager, ends the wrappers that could +/// relaunch the proxy, and then holds the port free for one relaunch period; with a registered +/// service the worst case of that sequence does not fit in 30 seconds, and a stop the CLI cannot +/// finish in the time it is given is refused before it changes anything. +pub const DEADLINE: Duration = drain_deadline_for(cfg!(target_os = "windows")); + +/// The drain deadline for a platform, split out so both answers stay testable on either. +pub const fn drain_deadline_for(windows: bool) -> Duration { + if windows { + Duration::from_secs(75) + } else { + Duration::from_secs(30) + } +} + +/// The environment variable that hands the stop's deadline to the CLI, in decimal milliseconds +/// measured from the child's start. +/// +/// An environment variable rather than the CLI's `--deadline-ms` flag, because an older bundled CLI +/// rejects a flag it does not know and ignores a variable it does not read, so a mixed pair of +/// versions keeps today's behavior. +pub const DEADLINE_ENV: &str = "OCX_STOP_DEADLINE_MS"; + +/// Startup time the consented takeover keeps back from the CLI: after the stop, the silence probe +/// and `ocx service claim` still run under the same startup deadline. +pub const TAKEOVER_MARGIN: Duration = Duration::from_secs(5); + +/// Drain time kept back from the CLI for spawning it and reading its answer. +pub const DRAIN_MARGIN: Duration = Duration::from_secs(3); + +/// The value for [`DEADLINE_ENV`]: what is left of the caller's deadline after its margin, in whole +/// milliseconds. `None` when nothing positive is left, so the variable is not set at all rather +/// than set to a deadline that has already passed. +pub fn deadline_env_value(remaining: Duration, margin: Duration) -> Option { + let millis = remaining.checked_sub(margin)?.as_millis(); + (millis > 0).then(|| millis.to_string()) +} /// The outcomes the CLI can report. An outcome this shell does not know fails to parse, which is /// the same answer as a stop that did not happen. @@ -99,6 +136,58 @@ pub struct StopSummary { pub runtime_down: bool, pub proxy: Proxy, pub message: String, + /// Why the Windows service stop did not succeed, when the CLI knows: `access-denied`, + /// `evidence-unreadable`, `unbound-wrapper` or `unverified-budget`. Additive within + /// `ocx-stop/1`, so an older CLI leaves it out; a reason this shell does not know is kept and + /// shown as it is, and no reason changes how the outcome is read. + #[serde(default)] + pub service_stop_reason: Option, + /// The CLI's own processes are gone but another program holds the runtime's port. Additive + /// within `ocx-stop/1`; absent or `null` means false, so a stray `null` never fails the parse. + #[serde(default)] + pub port_held_by_foreign: Option, +} + +impl StopSummary { + /// What the additive fields add to the CLI's message, in words a person can act on. + pub fn notes(&self) -> Option { + let mut notes = Vec::new(); + match self.service_stop_reason.as_deref() { + None => {} + Some("access-denied") => notes.push( + "Windows denied stopping the service; run `ocx stop` from an elevated terminal" + .to_owned(), + ), + Some("unverified-budget") => notes.push( + "the stop did not have enough time to finish safely, so nothing was changed; \ + retry with `ocx stop` from a terminal" + .to_owned(), + ), + Some("unbound-wrapper") => notes.push( + "a service wrapper started outside the service is still running and can restart \ + the runtime; end it, then stop again" + .to_owned(), + ), + Some("evidence-unreadable") => notes.push( + "the state of the service's processes could not be read, so the stop could not be \ + confirmed" + .to_owned(), + ), + Some(other) => notes.push(format!("service stop reason: {other}")), + } + if self.port_held_by_foreign == Some(true) { + notes.push("another program is holding the runtime's port".to_owned()); + } + (!notes.is_empty()).then(|| notes.join("; ")) + } + + /// The CLI's message with [`Self::notes`] appended. + pub fn described(&self) -> String { + match self.notes() { + Some(notes) => format!("{}; {notes}", self.message), + None => self.message.clone(), + } + } } /// What the shell concluded. @@ -129,7 +218,7 @@ impl StopResult { pub fn describe(&self) -> String { match self { - Self::Stopped(summary) => summary.message.clone(), + Self::Stopped(summary) => summary.described(), Self::ApprovalChanged(reason) => reason.clone(), Self::ManagerStillActive(reason) => reason.clone(), Self::HistoryIncomplete(reason) => reason.clone(), @@ -179,10 +268,10 @@ pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult )); } if summary.outcome == Outcome::ApprovalChanged { - return StopResult::ApprovalChanged(summary.message); + return StopResult::ApprovalChanged(summary.described()); } if summary.outcome == Outcome::ManagerStillActive { - return StopResult::ManagerStillActive(summary.message); + return StopResult::ManagerStillActive(summary.described()); } if summary.outcome == Outcome::HistoryIncomplete && exit_code == Some(HISTORY_INCOMPLETE_EXIT_CODE) @@ -193,7 +282,7 @@ pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult { return StopResult::HistoryIncomplete(format!( "{} (outcome {})", - summary.message, + summary.described(), summary.outcome.as_str() )); } @@ -211,7 +300,7 @@ pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult { return StopResult::Failed(format!( "{} (outcome {}, proxy {}, exit {}, process exit {})", - summary.message, + summary.described(), summary.outcome.as_str(), summary.proxy.as_str(), summary.exit_code, @@ -225,7 +314,13 @@ pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult /// Run the ordinary bundled stop used when this app exits its own runtime. pub async fn run(app: &AppHandle, deadline: Instant) -> StopResult { - run_with_args(app, deadline, vec!["stop".to_owned(), "--json".to_owned()]).await + run_with_args( + app, + deadline, + DRAIN_MARGIN, + vec!["stop".to_owned(), "--json".to_owned()], + ) + .await } /// Run the bundled stop bound to the approved runtime, under the caller's deadline. @@ -260,16 +355,26 @@ pub async fn run_approved( "--expect-compatibility-token".to_owned(), token.clone(), ]; - run_with_args(app, deadline, argv).await + run_with_args(app, deadline, TAKEOVER_MARGIN, argv).await } -async fn run_with_args(app: &AppHandle, deadline: Instant, argv: Vec) -> StopResult { - let command = match app.shell().sidecar("ocx") { +async fn run_with_args( + app: &AppHandle, + deadline: Instant, + margin: Duration, + argv: Vec, +) -> StopResult { + let mut command = match app.shell().sidecar("ocx") { Ok(command) => command.args(argv), Err(error) => { return StopResult::Failed(format!("the bundled CLI could not be started ({error})")) } }; + // Measured immediately before the spawn, so the CLI's clock and this one start together. + // Always set, so a value inherited from this process's own environment never reaches the + // child: an empty value means "no deadline" to the CLI, the same as an absent one. + let value = deadline_env_value(deadline.saturating_duration_since(Instant::now()), margin); + command = command.env(DEADLINE_ENV, value.unwrap_or_default()); match timeout_at( deadline, crate::process_spawn::with_spawn_async(command.output()), @@ -286,7 +391,11 @@ async fn run_with_args(app: &AppHandle, deadline: Instant, argv: Vec) -> #[cfg(test)] mod tests { - use super::{read, Outcome, Proxy, StopResult, SCHEMA}; + use super::{ + deadline_env_value, drain_deadline_for, read, Outcome, Proxy, StopResult, DEADLINE, + DRAIN_MARGIN, SCHEMA, TAKEOVER_MARGIN, + }; + use std::time::Duration; fn document(ok: bool, outcome: &str, exit: i32, down: bool, proxy: &str) -> String { format!( @@ -296,6 +405,167 @@ mod tests { ) } + /// The same document with additive fields spliced in after `message`. + fn with_fields(document: &str, fields: &str) -> String { + document.replace( + r#""message":"a message""#, + &format!(r#""message":"a message",{fields}"#), + ) + } + + #[test] + fn an_older_document_without_the_additive_fields_reads_as_it_does_today() { + let clean = document(true, "stopped", 0, true, "stopped"); + match read(Some(0), clean.as_bytes(), b"") { + StopResult::Stopped(summary) => { + assert_eq!(summary.service_stop_reason, None); + assert_eq!(summary.port_held_by_foreign, None); + assert_eq!(summary.notes(), None); + } + other => panic!("unexpected result: {other:?}"), + } + let refused = document(false, "manager-still-active", 1, false, "unknown"); + assert_eq!( + read(Some(1), refused.as_bytes(), b""), + StopResult::ManagerStillActive("a message".to_owned()) + ); + } + + #[test] + fn each_known_service_stop_reason_is_read_and_described() { + for (reason, expected) in [ + ("access-denied", "elevated terminal"), + ("unverified-budget", "from a terminal"), + ("unbound-wrapper", "started outside the service"), + ("evidence-unreadable", "could not be read"), + ] { + let refused = with_fields( + &document(false, "manager-still-active", 1, false, "unknown"), + &format!(r#""serviceStopReason":"{reason}""#), + ); + let result = read(Some(1), refused.as_bytes(), b""); + assert!( + matches!(&result, StopResult::ManagerStillActive(_)), + "{reason}: {result:?}" + ); + let text = result.describe(); + assert!(text.starts_with("a message"), "{reason}: {text}"); + assert!(text.contains(expected), "{reason}: {text}"); + + // The snapshotless path reports the same reason beside `failed`, and it stays a failure. + let failed = with_fields( + &document(false, "failed", 1, false, "unknown"), + &format!(r#""serviceStopReason":"{reason}""#), + ); + let result = read(Some(1), failed.as_bytes(), b""); + assert!(matches!(&result, StopResult::Failed(_)), "{reason}"); + assert!(result.describe().contains(expected), "{reason}"); + } + } + + #[test] + fn an_unknown_service_stop_reason_is_tolerated_and_changes_no_verdict() { + let clean = with_fields( + &document(true, "stopped", 0, true, "stopped"), + r#""serviceStopReason":"something-newer""#, + ); + let result = read(Some(0), clean.as_bytes(), b""); + assert!(result.is_stopped(), "{result:?}"); + assert!(result.describe().contains("something-newer")); + let refused = with_fields( + &document(false, "manager-still-active", 1, false, "unknown"), + r#""serviceStopReason":"something-newer""#, + ); + assert!(matches!( + read(Some(1), refused.as_bytes(), b""), + StopResult::ManagerStillActive(_) + )); + } + + #[test] + fn a_port_held_by_another_program_is_read_and_described() { + let failed = with_fields( + &document(false, "failed", 1, true, "stopped"), + r#""portHeldByForeign":true"#, + ); + let result = read(Some(1), failed.as_bytes(), b""); + assert!(matches!(&result, StopResult::Failed(_))); + assert!(result.describe().contains("another program"), "{result:?}"); + + // Reported beside a clean stop it is a warning, and the stop is still a stop. + let clean = with_fields( + &document(true, "stopped", 0, true, "stopped"), + r#""portHeldByForeign":true,"serviceStopReason":"access-denied""#, + ); + let result = read(Some(0), clean.as_bytes(), b""); + assert!(result.is_stopped()); + let text = result.describe(); + assert!(text.contains("another program"), "{text}"); + assert!(text.contains("elevated terminal"), "{text}"); + + let explicit_false = with_fields( + &document(true, "stopped", 0, true, "stopped"), + r#""portHeldByForeign":false"#, + ); + assert_eq!( + read(Some(0), explicit_false.as_bytes(), b"").describe(), + "a message" + ); + + let explicit_null = with_fields( + &document(true, "stopped", 0, true, "stopped"), + r#""portHeldByForeign":null"#, + ); + let result = read(Some(0), explicit_null.as_bytes(), b""); + assert!(result.is_stopped()); + assert_eq!(result.describe(), "a message"); + } + + #[test] + fn the_deadline_hand_off_keeps_each_margin_and_is_omitted_when_not_positive() { + assert_eq!(TAKEOVER_MARGIN, Duration::from_secs(5)); + assert_eq!(DRAIN_MARGIN, Duration::from_secs(3)); + // Consented takeover: the remaining startup deadline minus five seconds. + assert_eq!( + deadline_env_value(Duration::from_millis(83_250), TAKEOVER_MARGIN).as_deref(), + Some("78250") + ); + // Exit drain: the drain deadline minus three seconds. + assert_eq!( + deadline_env_value(Duration::from_secs(75), DRAIN_MARGIN).as_deref(), + Some("72000") + ); + assert_eq!( + deadline_env_value(Duration::from_secs(30), DRAIN_MARGIN).as_deref(), + Some("27000") + ); + // Nothing left after the margin, or less than a whole millisecond, is no deadline at all. + assert_eq!( + deadline_env_value(Duration::from_secs(5), TAKEOVER_MARGIN), + None + ); + assert_eq!( + deadline_env_value(Duration::from_secs(2), TAKEOVER_MARGIN), + None + ); + assert_eq!(deadline_env_value(Duration::ZERO, DRAIN_MARGIN), None); + assert_eq!( + deadline_env_value(Duration::from_micros(3_000_900), DRAIN_MARGIN), + None + ); + assert_eq!( + deadline_env_value(Duration::from_micros(3_001_900), DRAIN_MARGIN).as_deref(), + Some("1") + ); + } + + #[test] + fn only_windows_gets_the_longer_exit_drain() { + assert_eq!(drain_deadline_for(true), Duration::from_secs(75)); + assert_eq!(drain_deadline_for(false), Duration::from_secs(30)); + assert_eq!(DEADLINE, drain_deadline_for(cfg!(target_os = "windows"))); + } + #[test] fn a_clean_stop_with_the_runtime_down_is_the_only_success() { let ok = document(true, "stopped", 0, true, "stopped"); diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 8506553311f..b517be8c912 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -60,7 +60,7 @@ Part A: Windows only. The async `stopManager` change reaches every platform, but macOS and Linux keep today's timing and ignore the deadline. -`ocx stop` takes the deadline as an input, `--deadline-ms `, measured from its own process start. Desktop passes it on both paths: for the consented takeover, the remaining startup deadline minus a 5 s margin for the silence probe and `claim::run` that Desktop still runs under that deadline after the stop (desktop/src-tauri/src/startup.rs); for the exit drain, its deadline minus a 3 s spawn/parse margin. Without the flag (a terminal, a script, an older Desktop) the deadline is 60 s. The other callers (`ocx service stop`, `ocx uninstall`, service uninstall/remove, install/switch) are interactive and use 60 s; the updater uses its job budget. +`ocx stop` takes the deadline as an input, `--deadline-ms ` or the environment variable `OCX_STOP_DEADLINE_MS` (decimal milliseconds), measured from its own process start. Desktop passes it through the environment variable only, because an older CLI rejects an unknown flag and ignores an unknown variable, and does not set a value that would not be positive. It passes it on both paths: for the consented takeover, the remaining startup deadline minus a 5 s margin for the silence probe and `claim::run` that Desktop still runs under that deadline after the stop (desktop/src-tauri/src/startup.rs); for the exit drain, its deadline minus a 3 s spawn/parse margin. Without either (a terminal, a script, an older Desktop) the deadline is 60 s. The other callers (`ocx service stop`, `ocx uninstall`, service uninstall/remove, install/switch) are interactive and use 60 s; the updater uses its job budget. Admission. Part A's registration probe (step 1) runs first, so a machine with nothing registered returns `absent` and is never refused for budget. Then, before step 2 and after every pre-step has already run (on the consented path: `runResolve`, `describeTrackedRuntimeTarget` and the first `inspectGuardedManagerTarget` inside `runApprovedStop`, then `revalidateManager`, each with its existing CIM 5 s timeout and `schtasks`; their actual elapsed time is what counts), the stop computes the worst case of everything still to come from the table below. If the remaining time cannot hold it, the stop changes NOTHING (no `/end`, no WinSW stop, no terminate, no POST) and reports `serviceStopReason: "unverified-budget"` with `manager-still-active` (guarded) or `failed` (snapshotless). Every step after admission has a cap, and a step without a natural bound today gets one (below); the caps sum to at most the remaining time, so the graceful exit wait is never shortened and the stop does not overrun. @@ -82,7 +82,7 @@ Admission minimum with default settings and N = 0: guarded Task Scheduler 6 + 10 | Caller | Deadline | Fits? | |---|---|---| | consented takeover (`--expect-*`) | remaining startup deadline − 5 s (a Windows approval adds 60 s) | yes in practice; otherwise refused before anything changes | -| Desktop exit/restart drain (plain `ocx stop --json`) | today `runtime_stop::DEADLINE` − 3 s = 27 s | NO for a registered service. The same change raises Desktop's exit-drain deadline on Windows to 75 s (72 s passed as `--deadline-ms`). That holds the largest case (56 s) plus an 8 s allowance for what runs inside the deadline before admission (CLI start, the 2 s registration probe, and in the stray case the 5 s step-1 enumeration) plus two inherited receipts (3 × 2 = 6 s): 56 + 8 + 6 = 70 s ≤ 72 s; beyond that the stop is refused up front and the operator stops from a terminal. An older Desktop passes no flag, the CLI uses 60 s and can outlive Desktop's 30 s wait, which ends in `DrainVerdict::Failed`; today's stop can already outlive it (10 s POST + 8 s exit wait + 15 s port wait), so this is not new, and it is listed in 050 | +| Desktop exit/restart drain (plain `ocx stop --json`) | today `runtime_stop::DEADLINE` − 3 s = 27 s | NO for a registered service. The same change raises Desktop's exit-drain deadline on Windows to 75 s (72 s passed as `OCX_STOP_DEADLINE_MS`). That holds the largest case (56 s) plus an 8 s allowance for what runs inside the deadline before admission (CLI start, the 2 s registration probe, and in the stray case the 5 s step-1 enumeration) plus two inherited receipts (3 × 2 = 6 s): 56 + 8 + 6 = 70 s ≤ 72 s; beyond that the stop is refused up front and the operator stops from a terminal. An older Desktop passes no flag, the CLI uses 60 s and can outlive Desktop's 30 s wait, which ends in `DrainVerdict::Failed`; today's stop can already outlive it (10 s POST + 8 s exit wait + 15 s port wait), so this is not new, and it is listed in 050 | | terminal, scripts, other interactive callers | 60 s | yes | | `/api/stop` route | receipt with a part A record: no part A; receipt without the record: today's service stop, now the async part A, capped at the route's 6 s (an overrun answers `service_state_unknown`); WinSW: today's in-process stop; stray of a deleted task, no receipt: 4 s sweep (its enumeration capped at 4 s instead of today's 5 s; an overrun answers `service_state_unknown`, which fails safe) + teardown before the answer | the parent's 10 s POST abort, never shortened | | updater | job budget | yes | diff --git a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md index 7d8349b0868..46213c1f1a1 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -7,7 +7,7 @@ - Trigger races: a new instance during the stop is `survived`, not retried without bound. - Windows ARM64: the recommended fallback keeps today's ability to end a wrapper with a small accepted window between the in-script check and `Kill()`. If the owner prefers the strict variant (no kill without the handle primitive), ARM64 regresses from today: `ocx stop` and `ocx uninstall` report `unknown` wherever a wrapper survives. A third option closes the window on ARM64 too: an `Add-Type` P/Invoke inside the trusted PowerShell script that opens the process, reads its creation time and terminates it on the same handle. It costs a C# compile per stop (about a second) and can fail under Constrained Language Mode or antivirus; a compile failure would report `unknown`. - Not persistent: the proxy returns at the next logon/unlock trigger; stated in output text. -- Desktop exit drain: the drain's 30 s deadline cannot hold the worst case of a registered service, so the same release raises it to 75 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 75 s holds, admission refuses the exit drain up front (today that drain already overruns Desktop's wait). An older Desktop with a newer CLI passes no flag; the CLI then uses 60 s and can outlive Desktop's wait, which Desktop reports as a failed drain. Today's stop can already outlive it, so the pairing is no worse than now. +- Desktop exit drain: the drain's 30 s deadline cannot hold the worst case of a registered service, so the same release raises it to 75 s on Windows and passes the deadline in `OCX_STOP_DEADLINE_MS` (the CLI also accepts `--deadline-ms`). A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 75 s holds, admission refuses the exit drain up front (today that drain already overruns Desktop's wait). An older Desktop with a newer CLI passes no deadline; the CLI then uses 60 s and can outlive Desktop's wait, which Desktop reports as a failed drain. Today's stop can already outlive it, so the pairing is no worse than now. - Budget refusal: on a slow machine a stop can be refused up front with `unverified-budget` (nothing changed) where today it would have run and maybe overrun; the operator retries from a terminal (60 s). - Behavior change for scripts: `ocx stop`, `ocx service stop`, `ocx uninstall` and `/api/stop` report failure where they used to report success over a respawning wrapper. diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 9f080238590..27b465c895c 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -169,8 +169,10 @@ no proxy of this home is left running. A service that failed while the proxy hap satisfies the second and not the first, and it is exactly the case that may respawn the runtime a moment later. Nothing kills the child. -A drain that does not complete within `DRAIN_DEADLINE` is **not** recorded as a drain. It becomes -`DrainFailed`, and an unidentifiable runtime becomes `OwnershipUnknown`. A user's quit still +A drain that does not complete within `runtime_stop::DEADLINE` (30 seconds; 75 on Windows, where the +CLI's checked service stop needs longer, see [desktop supervision](ops/desktop-supervision.md)) is +**not** recorded as a drain. It becomes `DrainFailed`, and an unidentifiable runtime becomes +`OwnershipUnknown`. A user's quit still proceeds from either — refusing to close when the user asked is the worse answer, and a standing runtime is recoverable with `ocx stop`. A coordinated restart does not: coming back onto a runtime that was never stopped puts the user on the old version while they believe they upgraded. A runtime diff --git a/structure/ops/desktop-supervision.md b/structure/ops/desktop-supervision.md index fb27b68cc17..0ff5cab54ad 100644 --- a/structure/ops/desktop-supervision.md +++ b/structure/ops/desktop-supervision.md @@ -45,6 +45,22 @@ terminal before its own silence wait or claim. Only parsed `stopped` or validate (`desktop/src-tauri/src/claim.rs`), which rechecks the approved subject and compatibility. Declining attaches as a guest; a failed claim does not pretend a stopped runtime was restored. +Both `ocx stop` children Desktop runs — this guarded takeover and the exit/restart drain — receive +their deadline as `OCX_STOP_DEADLINE_MS` (decimal milliseconds from the child's start), computed in +`desktop/src-tauri/src/runtime_stop.rs` immediately before the spawn: the takeover passes the +remaining startup deadline minus five seconds, kept for the silence probe and the claim that still +run under it, and the drain passes its own deadline minus three seconds for spawning and parsing. +A value that would not be positive is not set. It is an environment variable and not the CLI's +`--deadline-ms` flag because an older bundled CLI rejects an unknown flag and ignores an unknown +variable, so mixed versions keep today's behavior. The drain's `runtime_stop::DEADLINE` is 75 +seconds on Windows, where a registered service's checked stop does not fit in the 30 seconds other +platforms keep. The summary's additive `serviceStopReason` (`access-denied`, +`evidence-unreadable`, `unbound-wrapper`, `unverified-budget`; an unknown string is kept and +shown as it is) and `portHeldByForeign` are optional in `StopSummary`, change no verdict, and only +append an actionable sentence to the stop's description — an elevated terminal, a retry from a +terminal, a wrapper started outside the service, unreadable process state, or another program on +the port. No `Outcome` or `Proxy` value was added, and an older CLI's document reads as before. + Live supervision is a third independent fact, alongside liveness and durable ownership. On macOS and Linux, the read-only probe verifies that the runtime's direct parent is `opencodex-desktop` and the child executable is that app's sibling `ocx`; two complete