diff --git a/devlog/_plan/261111_windows_service_stop/000_plan.md b/devlog/_plan/261111_windows_service_stop/000_plan.md new file mode 100644 index 00000000000..783cd00b0d5 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/000_plan.md @@ -0,0 +1,23 @@ +# 000 — Windows service stop that frees the port (#7771 Option B): design + +Status: design only, awaiting owner approval. Nothing here is implemented. + +## Problem + +On Windows with the Task Scheduler backend, neither `ocx stop` nor `ocx service stop` can prove that the proxy port is free afterwards. `schtasks /end` ends the task instance but often leaves the wrapper (`wscript.exe` running the launcher VBS, then `cmd.exe` running the service script) alive, and the wrapper's `:loop` relaunches the proxy about five seconds later. Desktop then sits at `taking-over`, a second `ocx start` loses the port, and an update can race a respawned runtime. Issue #7771 tracks this; its 2026-10-28 design comment proposed options A (diagnostics), B (one checked stop) and C (a cooperative wrapper handshake) and recommended A first, then B. + +What has shipped since: refusal diagnostics (#7973, option A), in-process command-line reads so the guarded takeover can verify the bundled runtime (#8487), and `ocx service restart` no longer reporting success while the old runtime answers (#8487). The port-freeing stop is what remains. + +## Documents + +| Doc | Content | +|---|---| +| 010 | Current stop paths, source-backed | +| 020 | Options and how they compare | +| 030 | Recommended design: one checked Windows stop | +| 040 | Verification: unit, hermetic integration, and real Windows device | +| 050 | Risks, compatibility and rollout | + +## Decision requested + +Approve 030 (recommended: option B split into part A, a checked manager-and-wrapper stop, and part B, the caller's port confirmation after its graceful child stop, used by every Windows stop path including service uninstall and install/backend switch: `ocx stop` with and without a consent snapshot, `ocx uninstall`, `/api/stop`, `ocx service stop` and the updater's Windows stop; an explicit binding rule including the stray-wrapper case; a new creation-time reader and handle-based terminate; outcomes mapped additively onto `ServiceStopOutcome`, `ocx-stop/1`, `/api/stop` and Desktop). Also decide the Windows ARM64 variant (recommended bounded fallback vs strict, 030/050). Implementation starts only after that approval and needs a Windows host for the acceptance checks in 040. diff --git a/devlog/_plan/261111_windows_service_stop/010_current_behavior.md b/devlog/_plan/261111_windows_service_stop/010_current_behavior.md new file mode 100644 index 00000000000..7a743969776 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/010_current_behavior.md @@ -0,0 +1,24 @@ +# 010 — Current Windows stop paths (source at origin/dev, 2026-11-11) + +## Wrapper respawn + +`buildWindowsServiceScript` (src/service/windows-taskxml.ts) emits a `:loop` that relaunches Bun or `ocx.exe` about five seconds after any child exit other than the protocol exit 42 (src/service/windows-wrapper-exit.ts). The registered task also carries restart-on-failure (PT1M, 3 attempts) and logon/session-unlock triggers. Killing only the proxy child brings it back without any Task Scheduler involvement. + +## Who calls which stop + +| Caller | Manager stop | Wrapper cleanup | Post-stop check | +|---|---|---|---| +| `ocx stop` without a consent snapshot, `ocx uninstall`, dashboard/Desktop `/api/stop` | `stopServiceIfInstalledDetailed` (src/service/orchestration.ts): `stopWindowsChecked` when the task is present, WinSW stop, outcome `absent / stopped / stopped-respawnable / failed / state-unknown` | `killWindowsServiceWrapperProcesses` after the manager stop | `ocx stop` re-polls when the scheduler can respawn and reports `respawned`; `/api/stop` maps `state-unknown` to `service_state_unknown`, refuses a respawnable Task Scheduler service for a caller without a receipt (`respawnable_service`), and cannot observe its own post-exit window | +| `ocx stop` with a consent snapshot (Desktop's approved stop) | `runGuardedManagerStep` (src/cli/stop-approval.ts): revalidates the bound manager, stops it, signals the approved PID when the manager does not cascade (`childNeedsSeparateStop`, true for Task Scheduler and WinSW) | none of its own | `observeGuardedManagerStopped` → `inactive / active / unknown`; reports `manager-still-active` or `respawned` | +| `ocx service uninstall` / `remove` | `stopWindows` then task deletion | none | none (a looping wrapper can outlive its task) | +| Service install / backend switch (`prepareServiceInstall`) | manager stops for the recorded and requested backends | `killWindowsServiceWrapperProcesses` | its tracked-proxy stop | +| `ocx service stop` | `platformOps(...).stop` = `stopWindows` (swallows non-benign `/end` errors) | none | `stopTrackedProxyForServiceCommand` then `proxyStillLiveAfterStop` polls ~7 s and exits 1 on a survivor | +| Updater (Windows restart/reclaim) | `guardedStopWindows` / `guardedStopWinsw` (src/update/guarded-runtime-ops.ts, job-windows-restart.ts) | its own `killWindowsServiceWrapperProcesses` through `guardedKillWindowsWrappers` | its own reclaim checks | + +So every path can already SEE a survivor (or at least a still-active manager); none can reliably FREE the port, because the wrapper cleanup (`killWindowsSchedulerWrappers`, src/lib/windows-service-wrappers.ts) is best effort: it matches this home's canonical launcher/script paths as complete command-line tokens, skips processes whose command line it cannot read, runs `Stop-Process -ErrorAction SilentlyContinue`, ignores the result and never re-observes. + +## Existing building blocks + +- `inspectWindowsGuardedManager` (src/service/windows-guarded-manager.ts) proves a manager owns an approved PID: the registered task must be a recognized OpenCodex layout (`windowsTaskRegistrationHealthy` or the exact legacy shape, via the `winRegistrationOurs` dependency), running, with an ancestor wrapper carrying this home's canonical paths; WinSW must be this installation's binary with the SCM PID as an ancestor. Unreadable evidence stays unknown. A surviving wrapper while the task reads not-running is `unknown("a surviving scheduler wrapper could not be tied to the registered task")`. +- `observeWindowsGuardedManagerStopped`: task not running, no wrapper carrying this home's paths, WinSW not started, no unreadable wrapper under the former manager. +- `readProcessCommandLine` reads one PID's command line in process on Windows x64 (#8487). Wrapper enumeration (`windowsProcessList`) still runs `Get-CimInstance Win32_Process`. `WindowsProcessEntry` carries pid, parentPid, name and commandLine, but no process start time. diff --git a/devlog/_plan/261111_windows_service_stop/020_options.md b/devlog/_plan/261111_windows_service_stop/020_options.md new file mode 100644 index 00000000000..a5149929384 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/020_options.md @@ -0,0 +1,10 @@ +# 020 — Options + +| Option | What it does | Fixes the port | Main cost / risk | +|---|---|---|---| +| B1. Checked stop over existing primitives | One operation: bind manager, wrapper and child identities; `/end`; terminate only the bound wrapper PIDs and child; re-observe until the port is free or a bounded deadline; report an explicit outcome | Yes for the common case | Needs a terminate primitive with identity revalidation immediately before each kill; native tests for elevated tasks | +| B2. B1 plus disabling the task during the stop | As B1, but `schtasks /change /disable` first so logon/session triggers and restart-on-failure cannot start a new instance mid-stop; re-enable on `service start` | Yes, including trigger races | Changes the registered task state; an interrupted stop must not leave the service disabled silently (needs a durable marker and recovery) | +| C. Cooperative intentional-stop handshake | The wrapper checks an authenticated stop file or exit code before relaunching | Yes for new wrappers | New cross-process protocol; legacy wrappers already installed still need B1 | +| A only (status quo) | Diagnostics and guidance only | No | The dead end in #7771 remains | + +Not considered acceptable: blanket `taskkill /T /F` by image name, port-owner killing without installation identity, or automatic elevation from Desktop. A task name, process name, port or path token alone is not kill authority (the #5443 consent boundary). diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md new file mode 100644 index 00000000000..8506553311f --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -0,0 +1,114 @@ +# 030 — Recommended design: one checked Windows stop (B1, with C as a later addition) + +## Two parts + +The proxy child is stopped by each caller AFTER the service stop, and `/api/stop` runs inside the very proxy that holds the port. A port check inside the service operation would therefore always time out. The design is split: + +- Part A, `stopWindowsServiceChecked()` (async; the current `stopServiceIfInstalledDetailed` and the guarded `stopManager` slot become async on Windows): stop the manager, terminate bound wrappers, then wait until the manager is inactive and no bound wrapper is alive. Returns `absent` / `stopped` / `survived` / `denied` / `unknown` for the manager and wrappers only. `absent` means no OpenCodex task or WinSW service is registered for this home and no wrapper is bound: it maps to `ServiceStopOutcome` `absent`, prints no "Service manager stopped.", lets plain `ocx stop` send its POST, and skips part B, so a Windows install without a service keeps today's timing. With nothing registered, a process list that cannot be read is also `absent` (the wrapper sweep is skipped, as today's best-effort kill silently skips it), never `unknown`, so such a machine is never refused a stop it gets today; the residual is a stray wrapper of a deleted task that cannot be seen, which today's sweep also misses. `unknown` is reserved for evidence that cannot be read about a registered manager or a bound candidate, and for a registration probe that itself cannot answer: an unreadable `schtasks` query (today's `state-unknown`) stays `unknown`, it never becomes `absent`. +- Part B, `confirmWindowsStopFreed()`, run by the caller after its graceful child stop: the port is free and stays free for the hold, and a remaining listener is classified as ours or foreign. Part B replaces `proxyStillLiveAfterStop` in `ocx service stop`, the `schedulerCanRespawn` re-poll in `ocx stop`, and the respawn-window check in `ocx uninstall` (src/cli/index.ts, uninstall-plan.ts), which today run only on `stopped-respawnable`; on Windows they run part B unconditionally after a part A `stopped`. On the guarded path part B is added after the existing `settle`, which keeps its approved-PID exit check (`waitExit`) and the probe of the approved endpoint; there "the port" is the approved endpoint. +- A part A outcome other than `stopped` aborts before anything destructive: `ocx service uninstall` / `remove` and `prepareServiceInstall` refuse with the outcome and reason before deleting or replacing the task (today uninstall only warns and continues), `ocx uninstall` keeps client configuration, and the updater aborts before replacing files. +- Plain `ocx stop` (no consent snapshot) treats a part A outcome other than `stopped` or `absent` exactly as the guarded path treats `manager-still-active`: it does not stop the child (no `/api/stop` POST), sets `stopFailed` and `ownershipBlocked`, and skips the shared teardown, so client configuration is never restored under a wrapper that can relaunch the proxy (#3008). Today this path still sends the receipt-backed POST after a failed manager stop and relies on the route re-running the service stop and answering 409; once the route skips part A for a receipt holder (below), that safety net would be gone, so the parent must not send the POST at all. The record uses existing values only: `service: failed` (or `state-unknown`), `proxy: unknown` (the child stop was not attempted), `sharedTeardown: skipped`, `outcome: failed`. + +## Where it sits + +Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" pair in every Windows stop path: + +- `stopServiceIfInstalledDetailed` (`ocx stop` without a snapshot, `ocx uninstall`, `/api/stop`) calls it instead of `stopWindowsChecked` + `killWindowsServiceWrapperProcesses`. +- `runGuardedManagerStep` calls it as its `stopManager` for a bound Task Scheduler/WinSW manager. The order stays: the operation ends the manager and the wrappers (so nothing can relaunch the child), then the existing `signalApproved` step stops the approved PID gracefully. `childNeedsSeparateStop` keeps its meaning. +- `ocx service stop` calls part A instead of `stopWindows`, then claims a teardown receipt (as `ocx stop` does) before stopping the tracked proxy, so the route defers the shared teardown; it completes and clears the receipt only after part B passes and keeps it for recovery otherwise. If the claim fails while this home owns the shared teardown, it does not send an undeferred child stop at all: it reports the failure and leaves the proxy running, because part A only covers wrappers it bound and a wrapper that appears later could relaunch the proxy after an undeferred teardown. The existing refusals and the not-owned teardown path are unchanged. +- `ocx service uninstall` / `remove` call part A before deleting the task (today they call `stopWindows` and kill no wrappers, so a looping wrapper can outlive its task, #764), then part B. +- `prepareServiceInstall` (service install and backend switch) calls part A instead of its manager stop + wrapper kill, then its existing tracked-proxy stop, then part B. A part B result other than a free port (our identity back, or `portHeldByForeign`) stops install and switch before they publish assets or start the service, with the port owner named; the service outcome stays `stopped` and no enum changes. +- `/api/stop`: when the caller presents a valid receipt, the parent `ocx stop` has already run part A, so the route skips part A (no second enumeration, `/end` or wait inside the proxy) and only performs its graceful shutdown; part B belongs to the parent, since the route cannot observe its own post-exit window. Only `ocx stop` and `ocx service stop` write the receipt, and each sends the POST only after its own part A returned `stopped` or `absent` (see "Two parts"). The receipt alone is not the proof: after part A the parent records its outcome in the receipt (`partA: "stopped" | "absent"`, with the bound manager and the wrapper set it ended as pid + creation time). The route skips part A only when that record is present for this attempt and the manager it names is still the registered one; a receipt without the record (an older CLI, or a receipt written before part A) gets today's route behavior, which re-runs the service stop and answers 409 on failure. Without a receipt the existing `respawnable_service` refusal stays for a respawnable Task Scheduler service. Where the respawn risk is `none` the route splits by case. For a registered WinSW service it keeps today's in-process service stop unchanged; part A is not run there, because WinSW's own stop ends this very process and cannot be capped from inside it. For a deleted task with a stray wrapper it runs only part A's wrapper sweep (bind, terminate, handle wait) with a 4 s cap, and then, before it answers, its own shared teardown (`performStopTeardown`, which a caller without a receipt still gets); the drain runs after the answer. Every parent POST gets the full 10 s abort, never a shortened one, and admission always counts it at 10 s; the implementation must measure the teardown's worst case and keep sweep plus teardown under those 10 s, and 040 carries that as a test. A sweep overrun answers `service_state_unknown` and changes nothing further. +- The updater's `stopWindowsServiceWrappersBestEffort` moves to part A in the same change (Task Scheduler backend; for the native WinSW backend it keeps today's behavior of stopping WinSW without a wrapper kill, because WinSW has no `:loop` wrapper), through an authorizer-gated variant (`guardedStopWindowsServiceChecked(authorize)`) that runs the authorizer once at entry and again before each terminate, as the existing `mutationGate` does, so an update cannot race a respawned runtime either. The updater keeps its own reclaim checks. Its three call sites in src/update/job.ts discard the result today, and one runs inside the synchronous `preparePort` callback; each gets a return path (the callback becomes async, or part A runs before it) so a non-`stopped` outcome really aborts the update before files are replaced. + +Desktop reaches it through two `ocx stop` children, both ordinary CLI processes run as the user: the consented takeover at startup (`--expect-*`, under the startup sequence's deadline, which `resolve_consent` extends by the consent wait plus 60 s on a Windows approval) and the exit/restart drain (plain `ocx stop --json` under `runtime_stop::DEADLINE`, 30 s, desktop/src-tauri/src/exit.rs). The #5443 boundary is unchanged and Desktop never elevates. + +## Binding rule (what may be terminated) + +A process is bound, and only a bound process may be terminated, when ALL hold: + +1. Image name is `wscript.exe`, `cscript.exe` or `cmd.exe` (wrapper). The proxy child is identified for observation and lineage only; it is never a terminate target of this operation. +2. Its command line contains this home's canonical launcher VBS or service script path as a complete token. +3. Lineage: it is an ancestor of the identity-checked child (`readPid()` identity), or a descendant of a BOUND wrapper, or (only while this home's OpenCodex task is registered) its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), or its parent is another bound wrapper, or is the Task Scheduler service host, proven by matching the Schedule service's process ID from the SCM (after `/end` the launcher `wscript.exe` usually still has it as parent). A wrapper that matches 1–2 but fails lineage (for example a launcher started by hand from a shell) is not terminated, and neither is its `cmd.exe` child (not a descendant of a bound wrapper); the outcome is `survived` with reason `unbound-wrapper` if either is still alive at the end of part A, and the output names the manual step. The "parent is gone" clause needs this home's task to be registered, so with no registered task an orphaned wrapper is never bound (`unbound-wrapper`). With the task registered, an orphaned wrapper cannot be told apart from a scheduler-owned stray whose launcher parent has exited, and both carry this home's paths and relaunch this home's proxy, so it is bound; that is the accepted residual. Ending a hand-started copy whose shell is still alive is left to the operator. For the stray-wrapper case (#764, task not running) this binds the launcher and, as its descendant, the `cmd.exe` running the `:loop`; ending either stops the relaunch. +4. A creation time was read for it at bind time. + +This is narrower than today's cleanup, which uses the path token alone with any image name and skips unreadable command lines silently. A candidate under the bound manager whose command line or creation time is unreadable makes the outcome `unknown`; it is never skipped and never killed. + +## Guarded (consent) path and the stray wrapper + +Today `inspectWindowsSchedulerManager` answers `unknown` when the task is not running but a wrapper survives, and `runApprovedStop` turns that into `approval-changed` before any manager step, which is #7771's Desktop symptom. The guarded inspection adopts the same binding rule: a not-running registered OpenCodex task plus wrappers bound by the rule above (path token, wrapper image, lineage to the identity-checked approved PID, creation time) is a provable manager state `bound-stray`, which reaches part A. `runGuardedManagerStep` (which today checks `kind === "bound"`) and `sameGuardedManager` both handle the new kind, and revalidation compares the bound wrapper set (pid + creation time). Anything the rule cannot bind stays `unknown` → `approval-changed`, and Desktop shows the existing refusal with the new `unbound-wrapper` / `evidence-unreadable` reason. + +## Termination primitive + +New work, named explicitly (x64): the creation-time reader, the handle terminate, and reading the Schedule service's PID from the SCM (`QueryServiceStatusEx`) for the parent proof. Making part A async changes the return type of `stopServiceIfInstalledDetailed` and the `stopManager` slot on every platform, so all their callers change in the same PR. + +- A Windows process creation-time reader (`GetProcessTimes`), added to `WindowsProcessEntry` as `startTime`. +- A handle-based terminate: `OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION)`, read the creation time on THAT handle, compare with the bound value, then `TerminateProcess` on the same handle, then close it. No gap between check and kill for PID reuse. Implemented with Bun FFI from `resolveTrustedWindowsSystemDirectory()` (same loading rules as #8487). +- Windows ARM64 (no FFI), recommended: keep today's capability with a weaker but bounded fallback rather than regress. Bind on the CIM `CreationDate` (a reader exists at src/codex/app-server-processes.ts), then in ONE trusted PowerShell script re-read the process's `StartTime` through `Get-Process -Id`, compare, and call `.Kill()` on the same `Process` object, reading the result back. `Process.Kill()` opens its own handle by PID, so a window between the in-script check and the kill remains; the CIM `CreationDate` and `StartTime` comparison uses a 1 s tolerance in UTC. Both are accepted residuals of the fallback and documented. The alternative, reporting `unknown` and killing nothing on ARM64, is listed in 050 as a regression the owner may prefer. + +## Steps + +Part A: + +1. Probe registration: `probeWindowsSchedulerTask` and, for WinSW, the service status (both capped at 2 s; an overrun or unreadable answer is `unknown`, nothing changed). With nothing registered, the process enumeration that looks for a stray wrapper of a deleted task (capped at 5 s) also runs here, before admission. Nothing registered and nothing bound → `absent`, return. +2. Bind (rule above) and record (pid, startTime, image, command-line digest). +3. Ask the manager. Task Scheduler: `schtasks /end` (cap 2 s). WinSW: a non-waiting stop (the stop command capped at 2 s instead of today's 15 s `SERVICE_COMMAND_TIMEOUT_MS`) followed by a capped SCM state poll (`sc query`) instead of today's `stopwait`; if WinSW reports stopped but the proxy is still alive, the outcome is `survived`; the poll's cap is the service's `` (20 s in the generated definition) plus 2 s, because WinSW itself stops the proxy within its stop timeout. Access denied → `denied`, stop there. A cap overrun → `unknown`, with nothing further changed (no terminate). +4. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). WinSW has no wrapper, so this step is empty there. The proxy child is NOT terminated here: it stops through the existing graceful path (`stopProxy` / `stopWithDeferral` / `signalApproved`) with its drain, the #7423 teardown-deferral receipt, and the rule that a received refusal (409, `ProxyOwnershipRefusedError`) forbids a forced stop. It is force-killed only where today's code already forces after a graceful timeout, never after a refusal. This operation gives no new kill authority over the child. +5. Wait (cap 3 s) until every bound wrapper has exited, by waiting on the process handles opened at bind time (no enumeration, no PowerShell during the wait), then read the manager state once with `schtasks /query` / `sc query` only (cap 1 s). The CIM enumeration that would find a wrapper started after bind is NOT repeated here; it runs once at the end of part B, where a late relaunch is what it has to catch. + +## Time budget (one deadline, checked before anything changes) + +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. + +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. + +Worst case still to come, at admission: + +| Step | Worst case | Notes | +|---|---|---| +| part A, Task Scheduler | guarded 6 s (`/end` 2 s, handle wait 3 s, manager state 1 s; the bind reuses `revalidateManager`'s enumeration); snapshotless 11 s (its own enumeration, 5 s, plus the same 6 s) | the registration probe already ran before admission | +| part A, WinSW | 25 s: stop command 2 s + SCM poll `` + 2 s = 22 s + manager state 1 s | WinSW stops the proxy itself, so the POST and exit-wait rows below do not apply to it. The stray enumeration of step 1 does not apply either: it runs only when nothing is registered | +| orphan discovery (snapshotless, no PID file; also after WinSW stopped the proxy) | 3 s | `findLiveProxy` and `proveLiveProxyOwnedByHome`, capped at 3 s together; an overrun is treated as "no proven orphan" and refuses, as an unproven owner does today | +| POST answer | 10 s, always | never shortened. The receipt and its part A record are written after admission (the snapshotless receipt inside `stopWithDeferral`), so admission cannot know whether they will exist and always counts the full abort. With the record the route skips part A and answers at once; without it (the write failed, or an older CLI) the route keeps today's behavior inside the same 10 s | +| child exit: graceful exit wait, then forced | `shutdownTimeoutMs` + 3 s + 10 s = 18 s by default | today's ladder, kept in sequence: the graceful exit wait (never shortened) runs after an answered POST; if the child has not exited, or the POST aborted, `stopProxy` forces (never after a refusal): `taskkill` with a new 5 s timeout (it has none today) plus today's `waitForExit` 5 s | +| inherited-receipt probes | 3 s × N | N abandoned receipts, known at admission; each probe tries both loopback hosts at today's 1.5 s per host (`probeEndpointLiveness`) | +| part B | 6 s floor, up to `min(12 s, remaining − 5 s − reserve)` | `waitForStoppedPort` (today a separate 15 s wait inside `stopProxy`) is the same port-free predicate and on Windows becomes part B's first phase; `settle`'s `waitExit` and endpoint probe are its first sample; the final CIM enumeration (`managerState` and `guardFinalStopSummary` share it) runs after the hold, capped at 5 s, and is counted: part B's worst case is 6 s + 5 s = 11 s. An unreadable result or a wrapper found that was not there at bind is non-success. Not run after a guarded `absent` manager (where `stopManager` is not called, as today). When admission lands exactly at the minimum, part B has no slack beyond its floor, so a slow port release reports failure instead of overrunning. Part B keeps today's `reclaimListenPort` clearing of a listener left by the exited child, and a listener whose owner has exited is not classified as `portHeldByForeign` | +| reserve | 3 s | shared restore (`restoreSharedClientStateAfterStop`; its worst case is measured in 040 and the reserve grows if it does not fit), `revertSystemEnv` (a real 1 s cap on what is a synchronous call today; an overrun is logged and left for the next start), and the JSON summary | + +Admission minimum with default settings and N = 0: guarded Task Scheduler 6 + 10 + 18 + 11 + 3 = 48 s; snapshotless Task Scheduler 11 + 3 + 10 + 18 + 11 + 3 = 56 s; WinSW snapshotless 25 + 3 + 11 + 3 = 42 s. The stray enumeration of a deleted task (5 s) runs in step 1, before admission, and so comes out of the time left at admission. + +| 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 | +| 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 | + +Part B (caller, after its graceful child stop): + +Part B, step 1. The listening port is free and stays free for one relaunch period (6 s hold), within the part B window from the budget above (6–12 s, starting when the caller's child stop returns), so a loop that relaunches late is caught. The single final CIM enumeration runs after the hold and must show no wrapper-image process with this home's path token (an unreadable result is non-success); one that appeared after bind makes the outcome `survived` even when the port is still free. A remaining listener is classified by identity as ours (`survived`) or foreign (`portHeldByForeign`). "The port" means the runtime-port record when present, else the port baked into the wrapper script (`windowsListenPort`), else the configured port. +Part B, step 2. Report (part A outcome combined with part B). + +## Outcomes and the output contract + +| Operation outcome | `ServiceStopOutcome` | `ocx-stop/1`, snapshotless path | `ocx-stop/1`, guarded (consent) path | `/api/stop` | Desktop | +|---|---|---|---|---|---| +| absent (nothing registered, nothing bound) | `absent` | today's values for no service | today's values | today's | today's | +| stopped | `stopped` | `outcome: stopped`, `service: stopped` | `outcome: stopped` | 200 | stopped | +| survived (bound wrapper or this installation's identity still on the port) | `failed` | part A survived: `outcome: failed`, `service: failed`, `proxy: unknown` (child not stopped, see "Two parts"); part B found our identity back on the port: `outcome: failed`, `proxy: respawned` (existing values) | `outcome: manager-still-active` (existing) | existing failure code | existing terminal handling | +| denied | `failed` | `outcome: failed` + additive `serviceStopReason: "access-denied"` | `outcome: manager-still-active` + additive `serviceStopReason: "access-denied"` | existing failure code + additive reason | elevation hint text | +| unknown | `state-unknown` | existing unknown handling + additive `serviceStopReason: "evidence-unreadable"` | existing unknown handling + additive reason | `service_state_unknown` (existing) | existing | +| port held by a FOREIGN identity after our processes are gone | `stopped` for the service | additive `portHeldByForeign: true` (a non-successful port confirmation: install and switch stop before starting the service) | additive `portHeldByForeign: true` | additive field | warning text | + +The reason strings `access-denied`, `evidence-unreadable`, `unbound-wrapper` and `unverified-budget` all travel in the one additive field `serviceStopReason`. On Windows the Task Scheduler backend no longer returns `stopped-respawnable`: part B now runs after every Windows `stopped`, which covers the respawn re-poll it used to trigger. The value stays in `StopServiceOutcome` for macOS/Linux and for documents written by older CLIs, and the route comments, the uninstall plan and `record.service` handling are updated in the same change. No new `outcome` or `proxy` enum value is introduced: Desktop's `Outcome` enum (desktop/src-tauri/src/runtime_stop.rs) fails to parse an unknown outcome. All new fields are additive within `ocx-stop/1` and are added to Desktop's `StopSummary` as optional fields with `serde(default)`; Desktop's parser (desktop/src-tauri/src/runtime_stop.rs) has no `deny_unknown_fields`, so a newer Desktop with an older package CLI behaves exactly as today (no new field → today's text), and an older Desktop with a newer CLI sees the same outcome strings. No schema bump. + +## Not persistent + +The stop ends the running instance only. Logon/session-unlock triggers and restart-on-failure stay enabled, so the proxy returns at the next unlock or logon unless the service is disabled or uninstalled. That is today's service semantics and is stated in the output text; persistent disable is option B2 and out of scope. + +## Later: C + +New wrapper scripts can check a per-installation stop token before relaunching; legacy wrappers keep needing B1. diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md new file mode 100644 index 00000000000..7bc683508b9 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -0,0 +1,49 @@ +# 040 — Verification + +## Hermetic (any CI host; existing non-live seams only: injected process table, scheduler probe, WinSW status, terminate primitive; no real schtasks/PowerShell) + +- Binding: wrapper with this home's paths and a wrapper image bound; same path token under a non-wrapper image not bound; another home's wrapper not bound; substring-only match not bound; stray wrapper with the task not running bound by the stray rule; unreadable command line or creation time under the bound manager → unknown, nothing killed. +- `/end` accepted, wrapper survives → handle terminate of the wrapper only → child stopped through the graceful path → stopped. +- The child refuses the graceful stop (409) → never force-killed; outcome survived. +- In the guarded path, the wrapper is ended before `signalApproved`, and the approved PID still receives its graceful signal and teardown-deferral receipt. +- Child exits 0 and the wrapper relaunches within the deadline → survived (no unbounded retry). +- Access denied on `/end` or terminate → denied, no further kills. +- Creation time changed between bind and terminate (PID reuse) → that PID untouched. +- Port still answered by this installation's identity → survived; by a foreign identity → stopped + `portHeldByForeign`. +- Order: wrappers end in part A, the child then stops through the caller's graceful path, and only then does part B check the port. A receipt-backed `/api/stop` whose receipt records part A `stopped`/`absent` for the current manager runs neither part A nor part B; a receipt without that record gets today's route behavior; a route without a receipt and risk `none` runs part A only (part B belongs to the caller). +- `portHeldByForeign` after part B: install and backend switch stop before publishing assets or starting the service. +- With no registered task, an orphaned wrapper (parent gone) is not bound → `unbound-wrapper`. +- `ocx service uninstall` and `prepareServiceInstall` end a looping wrapper before deleting or replacing the task. +- A wrapper that matches image and path but fails lineage is not terminated → survived with `unbound-wrapper`. +- ARM64 fallback (if approved): CIM creation time bind, in-script StartTime recheck, `Kill()` on the same object. +- `ocx uninstall` runs part B after a part A `stopped` and keeps client configuration on any other outcome; service uninstall/install refuse before deleting/replacing the task on a non-stopped outcome. +- `/api/stop` with a receipt skips part A; the whole consented stop fits the stated budget with injected clocks. +- Guarded path: a stray wrapper bound by the rule is `bound-stray` and reaches part A; an unbindable one stays approval-changed. +- Output mapping per path: `ServiceStopOutcome`, `ocx-stop/1` (no new outcome/proxy enum values; additive fields only), `/api/stop` codes, Desktop parser unit tests with old and new documents; updater variant refuses without authorization at entry and before each terminate. +- Plain `ocx stop`: part A `denied`, `survived` and `unknown` each send no `/api/stop` POST, set `ownershipBlocked`, skip the shared restore, and record `outcome: failed`, `proxy: unknown`, `sharedTeardown: skipped`. +- `absent`: no registered task or WinSW service and no bound wrapper → `ServiceStopOutcome` `absent`, no "Service manager stopped.", the POST is sent, part B does not run (no 6 s hold); the same with an unreadable process list → still `absent`, and plain `ocx stop` still stops the proxy. +- Budget, with injected clocks: admission refuses with `unverified-budget` and changes nothing (no `/end`, terminate or POST) when the remaining time is below the worst case still to come; once admitted, every step at its cap ends before the deadline; the graceful exit wait is never shortened; part B never gets less than 6 s; `--deadline-ms` from both Desktop paths is honored, and a missing flag means 60 s; macOS/Linux ignore it. +- `/api/stop` without a receipt, stray of a deleted task: the 4 s sweep plus the measured worst case of `performStopTeardown` answers within 10 s; every parent POST gets the full 10 s abort; a registered WinSW service keeps today's in-process stop. +- WinSW: a non-waiting stop plus an SCM poll capped at `` + 2 s; an overrun → `unknown` and nothing further; registration probes capped at 2 s, an unreadable `schtasks` answer stays `unknown`. +- The final enumeration runs after the 6 s hold; an unreadable result or a wrapper absent at bind → non-success; `ocx service stop` defers the teardown through a receipt and keeps it when part B fails; when the receipt claim fails and the teardown is owned, it sends no child stop and reports the failure; a wrapper that appears after part A is caught by part B before any teardown. +- The child exit ladder in sequence: an answered POST, a timed-out exit wait, then the forced kill (`taskkill` 5 s + `waitForExit` 5 s) all fit the admitted budget. +- Caps that are new: `taskkill` in the forced fallback times out at 5 s; orphan discovery at 3 s (overrun refuses); `revertSystemEnv` at 1 s; inherited-receipt probes counted as 3 s × N at admission (two hosts per probe); the measured worst case of `restoreSharedClientStateAfterStop` fits the reserve; a receipt without a part A record makes the route re-run part A within its 6 s cap. +- Admission runs after the registration probe: `absent` is never refused for budget. +- A wrapper relaunched after bind (port still free during the hold) is caught by the final enumeration → survived. +- Updater call sites, including the one in `preparePort`, abort on a non-`stopped` part A. +- New test files registered in the layout maps; no capped file grows (file-size ratchet). + +## Real Windows device (required before the implementation merges) + +| Case | Why it needs a device | +|---|---| +| Registered task, `/end` leaves the `wscript.exe` + `cmd.exe` loop alive; checked stop frees the port | Real Task Scheduler instance semantics | +| Killing the wrapper after `/end` does not fire restart-on-failure (PT1M × 3) | Scheduler retry behavior | +| Elevated (HighestAvailable) task stopped from an unelevated shell and from unelevated Desktop | Real access checks → denied; a command line hidden from a non-admin CIM query classifies as unknown or denied consistently | +| npm Bun runtime vs bundled `ocx.exe` | Different process trees and command lines | +| Logon/session-unlock trigger during a stop | Trigger timing | +| WinSW backend stop with `onfailure restart delay="5 sec"` against the WinSW cap (`` + 2 s) and the part B (6 s hold) deadline | Real SCM behavior | +| Slow startup / antivirus interference against the deadline | Timing on real hardware | +| Windows ARM64 | No FFI: bounded fallback (or unknown, if the owner chooses the strict variant) | + +Evidence per case (redacted: no tokens, runtime-port.json contents, environment dumps or full command lines): outcome JSON, whether the port is free afterwards (`Get-NetTCPConnection -LocalPort `), surviving process image names. diff --git a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md new file mode 100644 index 00000000000..7d8349b0868 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -0,0 +1,26 @@ +# 050 — Risks, compatibility, rollout + +## Risks + +- Killing the wrong process: only wrapper images are ever terminated; the proxy child keeps the graceful stop and its refusal rule. Per-installation binding with an image-name allowlist, lineage, a creation time read at bind time and re-checked on the same handle that terminates. Residual: a process that perfectly impersonates this home's wrapper (image, command line, lineage and creation time) cannot be told apart; the guarded takeover already carries that residual. +- Elevated tasks: an unelevated stop cannot terminate an elevated wrapper → `denied` with guidance; no automatic elevation. +- 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. +- 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. + +## Compatibility + +- Existing installations keep their wrappers; no reinstall needed for B1. +- `ocx-stop/1` and `/api/stop` change only additively; mixed Desktop/CLI versions keep today's behavior where a field is missing. +- macOS/Linux unchanged. + +## Rollout + +1. Implement the creation-time reader and handle terminate with hermetic tests. +2. Implement part A and part B and wire every call site from 030 (ocx stop both paths, ocx uninstall, /api/stop, ocx service stop, service uninstall/remove, prepareServiceInstall, the updater), including the guarded `bound-stray` state. +3. Run the device matrix (040) on Windows 11 x64 (npm and bundled), one elevated-task setup, and one Windows 11 ARM64 device for the chosen ARM64 variant; attach redacted evidence. If no ARM64 device is available, the owner must explicitly defer that case, and ARM64 then ships with the strict variant (report `unknown`, kill nothing) until it is run. +4. Desktop text for the additive fields in the same release. +5. Consider C for newly generated wrappers afterwards.