From 13f67b8a7bb098c0b41d5be2ecd79af6576e3f77 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 20:33:40 +0900 Subject: [PATCH 01/14] docs(devlog): design for a Windows service stop that frees the port Design only, for owner approval; nothing is implemented. Option B from the 2026-10-28 design comment as one checked stop shared by ocx stop and ocx service stop, with explicit stopped/survived/denied/unknown outcomes, bound per-installation identities, and a verification plan that separates hermetic tests from real Windows device checks. Refs #7771 --- .../261111_windows_service_stop/000_plan.md | 23 ++++++++++++++ .../010_current_behavior.md | 20 +++++++++++++ .../020_options.md | 10 +++++++ .../261111_windows_service_stop/030_design.md | 30 +++++++++++++++++++ .../040_verification.md | 24 +++++++++++++++ .../050_rollout_risks.md | 21 +++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 devlog/_plan/261111_windows_service_stop/000_plan.md create mode 100644 devlog/_plan/261111_windows_service_stop/010_current_behavior.md create mode 100644 devlog/_plan/261111_windows_service_stop/020_options.md create mode 100644 devlog/_plan/261111_windows_service_stop/030_design.md create mode 100644 devlog/_plan/261111_windows_service_stop/040_verification.md create mode 100644 devlog/_plan/261111_windows_service_stop/050_rollout_risks.md 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..dddd1c2528d --- /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 as one checked operation shared by `ocx stop` and `ocx service stop`, reusing the Windows guarded-manager binding, with explicit `stopped` / `survived` / `denied` / `unknown` outcomes and no new kill authority beyond this installation's proven wrapper and child). 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..644bde8f0e4 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/010_current_behavior.md @@ -0,0 +1,20 @@ +# 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 task XML also requests restart-on-failure and logon/session triggers. Killing only the proxy child therefore brings it back without any Task Scheduler involvement. + +## `ocx service stop` + +`platformOps("scheduler").stop` is `stopWindows` (src/service/windows-ops.ts): it runs `schtasks /end /tn opencodex-proxy` and swallows every non-benign error. `stopWindowsChecked` exists for the updater and reports only whether `/end` was accepted. Neither observes the wrapper or the child afterwards. The service stop path does not call the wrapper cleanup at all. + +## `ocx stop` + +Plain `ocx stop` stops the tracked proxy (identity-checked `readPid` / `findLiveProxy`, src/service/orchestration.ts) and attempts wrapper cleanup through `killWindowsSchedulerWrappers` (src/lib/windows-service-wrappers.ts). That cleanup is best effort by design: it matches this installation's canonical launcher and script paths as complete command-line tokens, but skips processes whose command line it cannot read, runs `Stop-Process -ErrorAction SilentlyContinue`, ignores the PowerShell exit status, and never re-observes. + +## Existing building blocks + +- `inspectWindowsGuardedManager` (src/service/windows-guarded-manager.ts) proves a Task Scheduler or WinSW manager owns an approved PID: the registered task must be a recognized OpenCodex layout, running, and an ancestor wrapper must carry this home's canonical paths; WinSW must be this installation's binary with the SCM PID as an ancestor. Unreadable evidence stays unknown. +- `observeWindowsGuardedManagerStopped` answers `inactive` / `active` / `unknown` after a stop: task not running, no wrapper carrying this home's paths, WinSW not started, and no unreadable wrapper under the former manager. +- `readProcessCommandLine` now reads command lines in process on Windows x64 (#8487), so wrapper and child identities are readable without CIM in the common case. +- `confirmServiceServing` with `replacing` (#8487) is the analogous "prove the replacement" check on the restart side. 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..245f1b647d2 --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -0,0 +1,30 @@ +# 030 — Recommended design: one checked Windows stop (B1, with C as a later addition) + +## Operation + +`stopWindowsServiceChecked(home)` is shared by `ocx stop` and `ocx service stop` on the Task Scheduler and WinSW backends. It returns one of: + +- `stopped`: the manager is inactive (observeWindowsGuardedManagerStopped = inactive) and nothing answers on the recorded port within the deadline. +- `survived`: a bound wrapper or child is still alive, or the port still answers with this installation's identity, after the deadline. +- `denied`: a terminate or `/end` failed with access denied (for example an elevated task stopped from an unelevated shell). +- `unknown`: identity evidence was unreadable; nothing was killed on that evidence. + +## Steps + +1. Bind. Read the registration (`windowsTaskRegistrationIsOurs` / WinSW binary path), the running wrapper PIDs that carry this home's canonical launcher/script paths as complete tokens, and the proxy child (`readPid` identity plus ancestor chain to a bound wrapper). Record each as (pid, process start time, command-line digest). Unreadable command line for a candidate under the bound manager makes the whole operation `unknown` rather than skipping it (fixes the silent skip in the current cleanup). +2. Ask the manager first. Task Scheduler: `schtasks /end`; WinSW: the existing stop. Treat non-benign errors as evidence (`denied` on access denied), not silence. +3. Terminate bound survivors only. For each bound wrapper, then the child: revalidate (pid, start time, command-line digest) immediately before terminating; a mismatch means PID reuse and the process is left alone. Termination uses `TerminateProcess` through a trusted path (in-process FFI where available, else trusted PowerShell `Stop-Process` with the result read back), never by image name. +4. Observe. Poll `observeWindowsGuardedManagerStopped` and the port (identity-checked `proxyIdentityAt`) until `stopped` or a bounded deadline (proposal: 10 s, covering the 5 s relaunch delay twice). +5. Report. `ocx stop --json` / `ocx service stop` print the outcome and the fixed reason; a non-`stopped` outcome exits non-zero and keeps client/runtime records (no cleanup claimed). Desktop maps `denied` to an elevation hint and `survived`/`unknown` to a terminal state with the reason, never auto-elevating. + +## Trigger races + +Logon/session triggers or restart-on-failure can start a new instance during the stop. B1 handles that by observing a new wrapper as `survived` (honest), not by retrying without bound. B2 (disable during stop) is deferred: it changes persistent task state and needs its own interrupted-stop recovery design. + +## Later: C + +New wrapper scripts can check a per-installation stop token before relaunching, making step 3 unnecessary in the common case. Legacy wrappers still go through B1, so C is an optimization, not a replacement. + +## Out of scope + +Elevating automatically, killing anything not bound in step 1, changing the update path's `stopWindowsChecked` semantics (it can adopt the new operation later), and macOS/Linux (launchd/systemd already stop their child and are verified by the guarded-manager path). 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..a4c031d8f8a --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -0,0 +1,24 @@ +# 040 — Verification + +## Hermetic (runs on any CI host; injected process table, scheduler and terminate seams) + +- Bind: wrapper with this home's paths bound; a same-name wrapper under another home, a substring-only match, and an unreadable command line under the bound manager (→ unknown, nothing killed). +- `/end` accepted but wrapper survives → terminate bound wrapper → stopped. +- Child exits 0 and the wrapper relaunches within the deadline → survived (no unbounded retry). +- Access denied on `/end` or terminate → denied, no further kills. +- PID reuse between bind and terminate (start time or digest changed) → that PID untouched. +- Port still answered by this installation's identity at the deadline → survived; answered by a foreign identity → reported as such, not stopped. +- Outcome mapping in `ocx stop --json`, `ocx service stop`, and Desktop's stop handling; exit codes; records kept on non-stopped. + +## Real Windows device (cannot be proven hermetically; required before merge of the implementation) + +| Case | Why it needs a device | +|---|---| +| Registered task, `/end` leaves `wscript.exe` + `cmd.exe` loop alive; checked stop frees the port | Real Task Scheduler instance semantics | +| Elevated (HighestAvailable) task stopped from an unelevated shell and from unelevated Desktop | Real token/integrity-level access checks → denied path | +| npm Bun runtime vs bundled `ocx.exe` | Different process trees and command lines | +| Logon/session trigger firing during a stop | Trigger timing | +| WinSW backend stop | Real SCM behavior | +| Windows ARM64 (no in-process FFI) | Fallback command-line read and terminate path | + +Evidence to collect 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 `), and the surviving process 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..de85645055a --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -0,0 +1,21 @@ +# 050 — Risks, compatibility, rollout + +## Risks + +- Killing the wrong process: mitigated by per-installation binding, start-time plus command-line digest revalidation immediately before each terminate, and treating unreadable evidence as unknown. Residual: a process that perfectly impersonates this home's wrapper command line and start time cannot be told apart, which already holds for the guarded takeover. +- Elevated tasks: an unelevated stop cannot terminate an elevated wrapper. The design reports `denied` with guidance instead of trying harder; Desktop never auto-elevates. +- Trigger races: reported as `survived`; B2 would close them at the cost of persistent task-state changes. +- Behavior change for scripts: `ocx stop` / `ocx service stop` exit non-zero where they used to print success over a respawning wrapper. That is the intended correction, called out in the release note. + +## Compatibility + +- Existing installations keep their wrappers; no reinstall is required for B1. +- macOS/Linux unchanged. +- The update path keeps `stopWindowsChecked` until it is migrated in a separate change. + +## Rollout + +1. Implement B1 behind the shared operation with the hermetic suite. +2. Run the device matrix in 040 on at least Windows 11 x64 (npm and bundled) and one elevated-task setup; attach redacted evidence to the PR. +3. Desktop outcome mapping in the same release. +4. Consider C (wrapper stop token) for newly generated wrappers afterwards. From 4f14f6b2eee35f9f9509de0117e2718df6a03e3f Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:00:57 +0900 Subject: [PATCH 02/14] docs(devlog): correct the Windows stop design against the real call paths Who calls which stop (stopWindowsChecked serves ocx stop, uninstall and /api/stop; the updater uses guardedStopWindows), the post-stop checks that already exist, an explicit binding rule including the stray wrapper, a creation-time reader and handle-based terminate as named new work, and the outcome mapping onto ServiceStopOutcome, ocx-stop/1, /api/stop and Desktop. Refs #7771 --- .../261111_windows_service_stop/000_plan.md | 2 +- .../010_current_behavior.md | 22 ++++--- .../261111_windows_service_stop/030_design.md | 62 ++++++++++++++----- .../040_verification.md | 30 +++++---- .../050_rollout_risks.md | 22 ++++--- 5 files changed, 87 insertions(+), 51 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/000_plan.md b/devlog/_plan/261111_windows_service_stop/000_plan.md index dddd1c2528d..07c7a864f88 100644 --- a/devlog/_plan/261111_windows_service_stop/000_plan.md +++ b/devlog/_plan/261111_windows_service_stop/000_plan.md @@ -20,4 +20,4 @@ What has shipped since: refusal diagnostics (#7973, option A), in-process comman ## Decision requested -Approve 030 (recommended: option B as one checked operation shared by `ocx stop` and `ocx service stop`, reusing the Windows guarded-manager binding, with explicit `stopped` / `survived` / `denied` / `unknown` outcomes and no new kill authority beyond this installation's proven wrapper and child). Implementation starts only after that approval and needs a Windows host for the acceptance checks in 040. +Approve 030 (recommended: option B as one checked operation used by every Windows stop path: `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). 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 index 644bde8f0e4..87ab661cd99 100644 --- a/devlog/_plan/261111_windows_service_stop/010_current_behavior.md +++ b/devlog/_plan/261111_windows_service_stop/010_current_behavior.md @@ -2,19 +2,21 @@ ## 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 task XML also requests restart-on-failure and logon/session triggers. Killing only the proxy child therefore brings it back without any Task Scheduler involvement. +`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. -## `ocx service stop` +## Who calls which stop -`platformOps("scheduler").stop` is `stopWindows` (src/service/windows-ops.ts): it runs `schtasks /end /tn opencodex-proxy` and swallows every non-benign error. `stopWindowsChecked` exists for the updater and reports only whether `/end` was accepted. Neither observes the wrapper or the child afterwards. The service stop path does not call the wrapper cleanup at all. +| 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` | +| `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 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 | -## `ocx stop` - -Plain `ocx stop` stops the tracked proxy (identity-checked `readPid` / `findLiveProxy`, src/service/orchestration.ts) and attempts wrapper cleanup through `killWindowsSchedulerWrappers` (src/lib/windows-service-wrappers.ts). That cleanup is best effort by design: it matches this installation's canonical launcher and script paths as complete command-line tokens, but skips processes whose command line it cannot read, runs `Stop-Process -ErrorAction SilentlyContinue`, ignores the PowerShell exit status, and never re-observes. +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 Task Scheduler or WinSW manager owns an approved PID: the registered task must be a recognized OpenCodex layout, running, and an ancestor wrapper must carry this home's canonical paths; WinSW must be this installation's binary with the SCM PID as an ancestor. Unreadable evidence stays unknown. -- `observeWindowsGuardedManagerStopped` answers `inactive` / `active` / `unknown` after a stop: task not running, no wrapper carrying this home's paths, WinSW not started, and no unreadable wrapper under the former manager. -- `readProcessCommandLine` now reads command lines in process on Windows x64 (#8487), so wrapper and child identities are readable without CIM in the common case. -- `confirmServiceServing` with `replacing` (#8487) is the analogous "prove the replacement" check on the restart side. +- `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/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 245f1b647d2..091c651757e 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -1,30 +1,58 @@ # 030 — Recommended design: one checked Windows stop (B1, with C as a later addition) -## Operation +## Where it sits -`stopWindowsServiceChecked(home)` is shared by `ocx stop` and `ocx service stop` on the Task Scheduler and WinSW backends. It returns one of: +A new `stopWindowsServiceChecked()` replaces, on Windows only, the "manager stop + best-effort wrapper kill" pair in every path from 010: -- `stopped`: the manager is inactive (observeWindowsGuardedManagerStopped = inactive) and nothing answers on the recorded port within the deadline. -- `survived`: a bound wrapper or child is still alive, or the port still answers with this installation's identity, after the deadline. -- `denied`: a terminate or `/end` failed with access denied (for example an elevated task stopped from an unelevated shell). -- `unknown`: identity evidence was unreadable; nothing was killed on that evidence. +- `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 approved-PID signal step stays, but the child is included in the bound set, so `childNeedsSeparateStop` is satisfied by the same operation. +- `ocx service stop` calls it instead of `stopWindows`; its existing `proxyStillLiveAfterStop` check stays as the final gate. +- The updater's `stopWindowsServiceWrappersBestEffort` moves to it in the same change, through an authorizer-gated variant (`guardedStopWindowsServiceChecked(authorize)`), so an update cannot race a respawned runtime either. The updater keeps its own reclaim checks. + +Desktop reaches it only through the consented `ocx stop` child; 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) or the recorded runtime executable (Bun or `ocx.exe`, child). +2. Wrapper: its command line contains this home's canonical launcher VBS or service script path as a complete token. Child: `readPid()` identity passes (recognized `start` command line for this home). +3. Lineage: the process is the identity-checked child, an ancestor of it, or a descendant of a wrapper that satisfies 1–2; when the task is not running (the common stray-wrapper case, #764), a wrapper satisfying 1–2 whose parent is gone or is another bound wrapper is bound by path token PLUS image name PLUS (when present) being an ancestor of the identity-checked child. +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. + +## Termination primitive + +New work, named explicitly: + +- 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); on Windows ARM64 (no FFI) the operation does not kill and reports `unknown`. ## Steps -1. Bind. Read the registration (`windowsTaskRegistrationIsOurs` / WinSW binary path), the running wrapper PIDs that carry this home's canonical launcher/script paths as complete tokens, and the proxy child (`readPid` identity plus ancestor chain to a bound wrapper). Record each as (pid, process start time, command-line digest). Unreadable command line for a candidate under the bound manager makes the whole operation `unknown` rather than skipping it (fixes the silent skip in the current cleanup). -2. Ask the manager first. Task Scheduler: `schtasks /end`; WinSW: the existing stop. Treat non-benign errors as evidence (`denied` on access denied), not silence. -3. Terminate bound survivors only. For each bound wrapper, then the child: revalidate (pid, start time, command-line digest) immediately before terminating; a mismatch means PID reuse and the process is left alone. Termination uses `TerminateProcess` through a trusted path (in-process FFI where available, else trusted PowerShell `Stop-Process` with the result read back), never by image name. -4. Observe. Poll `observeWindowsGuardedManagerStopped` and the port (identity-checked `proxyIdentityAt`) until `stopped` or a bounded deadline (proposal: 10 s, covering the 5 s relaunch delay twice). -5. Report. `ocx stop --json` / `ocx service stop` print the outcome and the fixed reason; a non-`stopped` outcome exits non-zero and keeps client/runtime records (no cleanup claimed). Desktop maps `denied` to an elevation hint and `survived`/`unknown` to a terminal state with the reason, never auto-elevating. +1. Bind (rule above) and record (pid, startTime, image, command-line digest). +2. Ask the manager: `schtasks /end` / WinSW stop. Access denied → `denied`, stop there. +3. Terminate bound survivors through the handle primitive, wrappers first, then the child. +4. Observe until `stopped` or the deadline (proposal: 10 s, two relaunch periods): `observeWindowsGuardedManagerStopped` = inactive AND the listening port is free. "The port" means the runtime-port record when present, else the port baked into the wrapper script (`windowsListenPort`), else the configured port. +5. Report. -## Trigger races +## Outcomes and the output contract -Logon/session triggers or restart-on-failure can start a new instance during the stop. B1 handles that by observing a new wrapper as `survived` (honest), not by retrying without bound. B2 (disable during stop) is deferred: it changes persistent task state and needs its own interrupted-stop recovery design. +| Operation outcome | `ServiceStopOutcome` | `ocx-stop/1` (`outcome` / `service`) | `/api/stop` | Desktop | +|---|---|---|---|---| +| stopped | `stopped` | `stopped` / `stopped` | 200 | stopped | +| survived (bound process or this installation's identity still on the port) | `failed` | `respawned` (existing) or `manager-still-active` (existing) | existing failure code | existing terminal handling | +| denied | `failed` | `manager-still-active`, with additive `serviceStopReason: "access-denied"` | existing failure code + additive reason | elevation hint text | +| unknown | `state-unknown` | existing unknown handling, additive `serviceStopReason: "evidence-unreadable"` | `service_state_unknown` (existing) | existing | +| port held by a FOREIGN identity after our processes are gone | `stopped` for the service | additive `portHeldByForeign: true` | additive field | warning text | -## Later: C +All new fields are additive within `ocx-stop/1`; Desktop's parser (desktop/src-tauri/src/runtime_stop.rs) ignores 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. -New wrapper scripts can check a per-installation stop token before relaunching, making step 3 unnecessary in the common case. Legacy wrappers still go through B1, so C is an optimization, not a replacement. +## Not persistent -## Out of scope +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 -Elevating automatically, killing anything not bound in step 1, changing the update path's `stopWindowsChecked` semantics (it can adopt the new operation later), and macOS/Linux (launchd/systemd already stop their child and are verified by the guarded-manager path). +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 index a4c031d8f8a..cb0da122019 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -1,24 +1,28 @@ # 040 — Verification -## Hermetic (runs on any CI host; injected process table, scheduler and terminate seams) +## Hermetic (any CI host; existing non-live seams only: injected process table, scheduler probe, WinSW status, terminate primitive; no real schtasks/PowerShell) -- Bind: wrapper with this home's paths bound; a same-name wrapper under another home, a substring-only match, and an unreadable command line under the bound manager (→ unknown, nothing killed). -- `/end` accepted but wrapper survives → terminate bound wrapper → stopped. +- 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 → stopped. - Child exits 0 and the wrapper relaunches within the deadline → survived (no unbounded retry). - Access denied on `/end` or terminate → denied, no further kills. -- PID reuse between bind and terminate (start time or digest changed) → that PID untouched. -- Port still answered by this installation's identity at the deadline → survived; answered by a foreign identity → reported as such, not stopped. -- Outcome mapping in `ocx stop --json`, `ocx service stop`, and Desktop's stop handling; exit codes; records kept on non-stopped. +- 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`. +- ARM64 path (no FFI) → unknown, no kill. +- Output mapping: `ServiceStopOutcome`, `ocx-stop/1` fields (additive), `/api/stop` codes, Desktop parser unit tests with old and new documents; updater variant refuses without authorization. +- New test files registered in the layout maps; no capped file grows (file-size ratchet). -## Real Windows device (cannot be proven hermetically; required before merge of the implementation) +## Real Windows device (required before the implementation merges) | Case | Why it needs a device | |---|---| -| Registered task, `/end` leaves `wscript.exe` + `cmd.exe` loop alive; checked stop frees the port | Real Task Scheduler instance semantics | -| Elevated (HighestAvailable) task stopped from an unelevated shell and from unelevated Desktop | Real token/integrity-level access checks → denied path | +| 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 trigger firing during a stop | Trigger timing | -| WinSW backend stop | Real SCM behavior | -| Windows ARM64 (no in-process FFI) | Fallback command-line read and terminate path | +| Logon/session-unlock trigger during a stop | Trigger timing | +| WinSW backend stop with `onfailure restart delay="5 sec"` against the 10 s deadline | Real SCM behavior | +| Slow startup / antivirus interference against the deadline | Timing on real hardware | +| Windows ARM64 | No FFI: unknown path | -Evidence to collect 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 `), and the surviving process names. +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 index de85645055a..b05334b45e1 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -2,20 +2,22 @@ ## Risks -- Killing the wrong process: mitigated by per-installation binding, start-time plus command-line digest revalidation immediately before each terminate, and treating unreadable evidence as unknown. Residual: a process that perfectly impersonates this home's wrapper command line and start time cannot be told apart, which already holds for the guarded takeover. -- Elevated tasks: an unelevated stop cannot terminate an elevated wrapper. The design reports `denied` with guidance instead of trying harder; Desktop never auto-elevates. -- Trigger races: reported as `survived`; B2 would close them at the cost of persistent task-state changes. -- Behavior change for scripts: `ocx stop` / `ocx service stop` exit non-zero where they used to print success over a respawning wrapper. That is the intended correction, called out in the release note. +- Killing the wrong process: 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. +- Not persistent: the proxy returns at the next logon/unlock trigger; stated in output text. +- 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 is required for B1. +- 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. -- The update path keeps `stopWindowsChecked` until it is migrated in a separate change. ## Rollout -1. Implement B1 behind the shared operation with the hermetic suite. -2. Run the device matrix in 040 on at least Windows 11 x64 (npm and bundled) and one elevated-task setup; attach redacted evidence to the PR. -3. Desktop outcome mapping in the same release. -4. Consider C (wrapper stop token) for newly generated wrappers afterwards. +1. Implement the creation-time reader and handle terminate with hermetic tests. +2. Implement `stopWindowsServiceChecked` and wire all four callers from 030, including the updater variant. +3. Run the device matrix (040) on Windows 11 x64 (npm and bundled) and one elevated-task setup; attach redacted evidence. +4. Desktop text for the additive fields in the same release. +5. Consider C for newly generated wrappers afterwards. From 11c0ac06c516e26d51290402fabfecf4be384003 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:03:07 +0900 Subject: [PATCH 03/14] docs(devlog): terminate only wrappers; map outcomes onto existing ocx-stop/1 values The checked stop never hard-kills the proxy child: it keeps the graceful stop, its drain, the teardown-deferral receipt and the refusal rule, after the operation has ended the manager and wrappers. The outcome table uses only existing outcome/proxy values per path (respawned is a proxy value under outcome failed), plus additive fields. Also: the scheduler service host as a launcher parent, a minimum observation window replacing the respawnable re-poll, and the updater authorizer before each terminate. Refs #7771 --- .../261111_windows_service_stop/030_design.md | 30 +++++++++---------- .../040_verification.md | 6 ++-- .../050_rollout_risks.md | 2 +- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 091c651757e..93c87794d80 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -5,9 +5,9 @@ A new `stopWindowsServiceChecked()` replaces, on Windows only, the "manager stop + best-effort wrapper kill" pair in every path from 010: - `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 approved-PID signal step stays, but the child is included in the bound set, so `childNeedsSeparateStop` is satisfied by the same operation. +- `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 it instead of `stopWindows`; its existing `proxyStillLiveAfterStop` check stays as the final gate. -- The updater's `stopWindowsServiceWrappersBestEffort` moves to it in the same change, through an authorizer-gated variant (`guardedStopWindowsServiceChecked(authorize)`), so an update cannot race a respawned runtime either. The updater keeps its own reclaim checks. +- The updater's `stopWindowsServiceWrappersBestEffort` moves to it in the same change, 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. Desktop reaches it only through the consented `ocx stop` child; the #5443 boundary is unchanged and Desktop never elevates. @@ -15,9 +15,9 @@ Desktop reaches it only through the consented `ocx stop` child; the #5443 bounda 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) or the recorded runtime executable (Bun or `ocx.exe`, child). -2. Wrapper: its command line contains this home's canonical launcher VBS or service script path as a complete token. Child: `readPid()` identity passes (recognized `start` command line for this home). -3. Lineage: the process is the identity-checked child, an ancestor of it, or a descendant of a wrapper that satisfies 1–2; when the task is not running (the common stray-wrapper case, #764), a wrapper satisfying 1–2 whose parent is gone or is another bound wrapper is bound by path token PLUS image name PLUS (when present) being an ancestor of the identity-checked child. +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 wrapper that satisfies 1–2, or its parent is gone, is another bound wrapper, or is the Task Scheduler service host (`svchost.exe` running the Schedule service; after `/end` the launcher `wscript.exe` usually still has it as parent). 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. @@ -33,21 +33,21 @@ New work, named explicitly: 1. Bind (rule above) and record (pid, startTime, image, command-line digest). 2. Ask the manager: `schtasks /end` / WinSW stop. Access denied → `denied`, stop there. -3. Terminate bound survivors through the handle primitive, wrappers first, then the child. -4. Observe until `stopped` or the deadline (proposal: 10 s, two relaunch periods): `observeWindowsGuardedManagerStopped` = inactive AND the listening port is free. "The port" means the runtime-port record when present, else the port baked into the wrapper script (`windowsListenPort`), else the configured port. +3. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). 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. +4. Observe until `stopped` or the deadline (proposal: 10 s, two relaunch periods): `observeWindowsGuardedManagerStopped` = inactive AND the listening port is free, and both must still hold after one full relaunch period (6 s) so a loop that relaunches late is caught. This observation replaces the `schedulerCanRespawn` re-poll in `ocx stop` that `stopped-respawnable` triggers today. "The port" means the runtime-port record when present, else the port baked into the wrapper script (`windowsListenPort`), else the configured port. 5. Report. ## Outcomes and the output contract -| Operation outcome | `ServiceStopOutcome` | `ocx-stop/1` (`outcome` / `service`) | `/api/stop` | Desktop | -|---|---|---|---|---| -| stopped | `stopped` | `stopped` / `stopped` | 200 | stopped | -| survived (bound process or this installation's identity still on the port) | `failed` | `respawned` (existing) or `manager-still-active` (existing) | existing failure code | existing terminal handling | -| denied | `failed` | `manager-still-active`, with additive `serviceStopReason: "access-denied"` | existing failure code + additive reason | elevation hint text | -| unknown | `state-unknown` | existing unknown handling, additive `serviceStopReason: "evidence-unreadable"` | `service_state_unknown` (existing) | existing | -| port held by a FOREIGN identity after our processes are gone | `stopped` for the service | additive `portHeldByForeign: true` | additive field | warning text | +| Operation outcome | `ServiceStopOutcome` | `ocx-stop/1`, snapshotless path | `ocx-stop/1`, guarded (consent) path | `/api/stop` | Desktop | +|---|---|---|---|---|---| +| stopped | `stopped` | `outcome: stopped`, `service: stopped` | `outcome: stopped` | 200 | stopped | +| survived (bound wrapper or this installation's identity still on the port) | `failed` | `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` | additive `portHeldByForeign: true` | additive field | warning text | -All new fields are additive within `ocx-stop/1`; Desktop's parser (desktop/src-tauri/src/runtime_stop.rs) ignores 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. +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`; Desktop's parser (desktop/src-tauri/src/runtime_stop.rs) ignores 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 diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index cb0da122019..e7140a2b6dc 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -3,13 +3,15 @@ ## 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 → stopped. +- `/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`. - ARM64 path (no FFI) → unknown, no kill. -- Output mapping: `ServiceStopOutcome`, `ocx-stop/1` fields (additive), `/api/stop` codes, Desktop parser unit tests with old and new documents; updater variant refuses without authorization. +- 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. - New test files registered in the layout maps; no capped file grows (file-size ratchet). ## Real Windows device (required before the implementation merges) 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 b05334b45e1..500db2f97cc 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -2,7 +2,7 @@ ## Risks -- Killing the wrong process: 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. +- 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. - Not persistent: the proxy returns at the next logon/unlock trigger; stated in output text. From 2478763a06e5e328345a2515884d2f2bc1296aad Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:06:50 +0900 Subject: [PATCH 04/14] docs(devlog): split the Windows stop into a manager/wrapper part and a caller port check Callers stop the child after the service stop and /api/stop runs inside the proxy, so the port check moves to the caller after its graceful child stop. Adds ocx service uninstall/remove and prepareServiceInstall to the covered paths, proves the scheduler parent through the Schedule service PID, compares parent creation times, states the unbound-wrapper outcome and the deadlines, keeps WinSW updater behavior, and resolves ARM64 with a recommended bounded fallback (owner choice). Refs #7771 --- .../261111_windows_service_stop/000_plan.md | 2 +- .../010_current_behavior.md | 4 ++- .../261111_windows_service_stop/030_design.md | 35 ++++++++++++++----- .../040_verification.md | 5 ++- .../050_rollout_risks.md | 1 + 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/000_plan.md b/devlog/_plan/261111_windows_service_stop/000_plan.md index 07c7a864f88..783cd00b0d5 100644 --- a/devlog/_plan/261111_windows_service_stop/000_plan.md +++ b/devlog/_plan/261111_windows_service_stop/000_plan.md @@ -20,4 +20,4 @@ What has shipped since: refusal diagnostics (#7973, option A), in-process comman ## Decision requested -Approve 030 (recommended: option B as one checked operation used by every Windows stop path: `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). Implementation starts only after that approval and needs a Windows host for the acceptance checks in 040. +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 index 87ab661cd99..7a743969776 100644 --- a/devlog/_plan/261111_windows_service_stop/010_current_behavior.md +++ b/devlog/_plan/261111_windows_service_stop/010_current_behavior.md @@ -8,8 +8,10 @@ | 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` | +| `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 | diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 93c87794d80..b09ea377b91 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -1,13 +1,23 @@ # 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 `stopped` / `survived` / `denied` / `unknown` for the manager and wrappers only. +- 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 guarded path's `settle` / `managerState` observation. + ## Where it sits -A new `stopWindowsServiceChecked()` replaces, on Windows only, the "manager stop + best-effort wrapper kill" pair in every path from 010: +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 it instead of `stopWindows`; its existing `proxyStillLiveAfterStop` check stays as the final gate. -- The updater's `stopWindowsServiceWrappersBestEffort` moves to it in the same change, 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. +- `ocx service stop` calls part A instead of `stopWindows`, stops the tracked proxy as today, then runs part B. +- `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. +- `/api/stop` runs part A only. Part B belongs to the parent `ocx stop` that holds the receipt, since the route cannot observe its own post-exit window; the existing `respawnable_service` refusal for a caller without a receipt stays. +- 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. Desktop reaches it only through the consented `ocx stop` child; the #5443 boundary is unchanged and Desktop never elevates. @@ -17,25 +27,32 @@ 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 wrapper that satisfies 1–2, or its parent is gone, is another bound wrapper, or is the Task Scheduler service host (`svchost.exe` running the Schedule service; after `/end` the launcher `wscript.exe` usually still has it as parent). 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. +3. Lineage: it is an ancestor of the identity-checked child (`readPid()` identity), or a descendant of a wrapper that satisfies 1–2, or its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), 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) is not terminated and makes the outcome `survived` with reason `unbound-wrapper` if it is still alive at the end of part A. 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. ## Termination primitive -New work, named explicitly: +New work, named explicitly (x64): - 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); on Windows ARM64 (no FFI) the operation does not kill and reports `unknown`. +- 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. The window between the CIM read and the check is closed; the remaining window between the in-script check and `Kill()` is accepted and documented. The alternative, reporting `unknown` and killing nothing on ARM64, is listed in 050 as a regression the owner may prefer. ## Steps 1. Bind (rule above) and record (pid, startTime, image, command-line digest). 2. Ask the manager: `schtasks /end` / WinSW stop. Access denied → `denied`, stop there. +Part A: + 3. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). 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. -4. Observe until `stopped` or the deadline (proposal: 10 s, two relaunch periods): `observeWindowsGuardedManagerStopped` = inactive AND the listening port is free, and both must still hold after one full relaunch period (6 s) so a loop that relaunches late is caught. This observation replaces the `schedulerCanRespawn` re-poll in `ocx stop` that `stopped-respawnable` triggers today. "The port" means the runtime-port record when present, else the port baked into the wrapper script (`windowsListenPort`), else the configured port. -5. Report. +4. Wait until `observeWindowsGuardedManagerStopped` = inactive and no bound wrapper is alive (part A deadline: 5 s, starting after the manager stop returns). + +Part B (caller, after its graceful child stop): + +5. The listening port is free and stays free for one relaunch period (6 s hold), within a 12 s budget that starts when the caller's child stop returns, so a loop that relaunches late is caught. 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. +6. Report (part A outcome combined with part B). ## Outcomes and the output contract @@ -47,7 +64,7 @@ New work, named explicitly: | 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` | additive `portHeldByForeign: true` | additive field | warning text | -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`; Desktop's parser (desktop/src-tauri/src/runtime_stop.rs) ignores 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. +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 diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index e7140a2b6dc..85573536eb1 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -10,7 +10,10 @@ - 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`. -- ARM64 path (no FFI) → unknown, no kill. +- 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; `/api/stop` runs part A only. +- `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. - 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. - New test files registered in the layout maps; no capped file grows (file-size ratchet). 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 500db2f97cc..bcfd2038c1d 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -5,6 +5,7 @@ - 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. - Not persistent: the proxy returns at the next logon/unlock trigger; stated in output text. - 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. From 923e948d695b0e047ecb0141601f6829a2446900 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:13:41 +0900 Subject: [PATCH 05/14] docs(devlog): give uninstall its port check, budget the consented stop, close the binding gaps Part B now also replaces the uninstall respawn-window check and runs after the guarded settle (kept); non-stopped outcomes abort before destructive steps; /api/stop with a receipt skips part A; worst-case budgets per caller (28 s against Desktop 30 s); descendant of a BOUND wrapper and an explicit choice for hand-started copies; the guarded inspection gains a bound-stray state; new work and the async return type listed; ARM64 fallback residuals stated; stale deadline and caller counts fixed. Refs #7771 --- .../261111_windows_service_stop/030_design.md | 24 +++++++++++++++---- .../040_verification.md | 7 ++++-- .../050_rollout_risks.md | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index b09ea377b91..861e07c59c3 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -5,7 +5,8 @@ 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 `stopped` / `survived` / `denied` / `unknown` for the manager and wrappers only. -- 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 guarded path's `settle` / `managerState` observation. +- 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. ## Where it sits @@ -16,7 +17,7 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `ocx service stop` calls part A instead of `stopWindows`, stops the tracked proxy as today, then runs part B. - `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. -- `/api/stop` runs part A only. Part B belongs to the parent `ocx stop` that holds the receipt, since the route cannot observe its own post-exit window; the existing `respawnable_service` refusal for a caller without a receipt stays. +- `/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. Without a receipt the existing `respawnable_service` refusal stays. This keeps the route well inside the parent's 10 s POST abort, after which the parent falls back to a forced kill. - 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. Desktop reaches it only through the consented `ocx stop` child; the #5443 boundary is unchanged and Desktop never elevates. @@ -27,18 +28,22 @@ 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 wrapper that satisfies 1–2, or its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), 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) is not terminated and makes the outcome `survived` with reason `unbound-wrapper` if it is still alive at the end of part A. 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. +3. Lineage: it is an ancestor of the identity-checked child (`readPid()` identity), or a descendant of a BOUND wrapper, or its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), 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. Ending a hand-started copy is left to the operator by design. 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. 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): +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. The window between the CIM read and the check is closed; the remaining window between the in-script check and `Kill()` is accepted and documented. The alternative, reporting `unknown` and killing nothing on ARM64, is listed in 050 as a regression the owner may prefer. +- 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 @@ -49,6 +54,15 @@ Part A: 3. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). 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. 4. Wait until `observeWindowsGuardedManagerStopped` = inactive and no bound wrapper is alive (part A deadline: 5 s, starting after the manager stop returns). +## Time budget per caller (worst case) + +| Caller | Part A | Child stop | Part B | Bound to fit | +|---|---|---|---|---| +| `ocx stop` (Desktop consented) | bind + `/end` + terminate + 5 s | `/api/stop` graceful drain (`shutdownTimeoutMs` + 3 s), route skips part A | 12 s | Desktop's 30 s whole-stop limit: part A 8 s (one CIM enumeration up front, then process handles only during the wait) + drain 8 s (default `shutdownTimeoutMs` 5 s + 3 s) + part B 12 s = 28 s. With a configured `shutdownTimeoutMs` above 7 s, part B gets only the remaining time, never less than one 6 s relaunch period; if even that does not fit, the stop reports `survived` with reason `unverified-budget` rather than overrun | +| `/api/stop` route | skipped with a receipt | its own drain | none | parent's 10 s POST abort | +| `ocx service stop`, `ocx uninstall`, service uninstall, install/switch | 8 s | tracked-proxy stop | 12 s | interactive, no fixed external limit | +| updater | 8 s | its own | its own reclaim | update job budget | + Part B (caller, after its graceful child stop): 5. The listening port is free and stays free for one relaunch period (6 s hold), within a 12 s budget that starts when the caller's child stop returns, so a loop that relaunches late is caught. 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. diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index 85573536eb1..a510c1e0084 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -14,6 +14,9 @@ - `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. - New test files registered in the layout maps; no capped file grows (file-size ratchet). @@ -26,8 +29,8 @@ | 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 10 s deadline | Real SCM behavior | +| WinSW backend stop with `onfailure restart delay="5 sec"` against the part A (5 s wait) and part B (6 s hold) deadlines | Real SCM behavior | | Slow startup / antivirus interference against the deadline | Timing on real hardware | -| Windows ARM64 | No FFI: unknown path | +| 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 index bcfd2038c1d..c3530013122 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -18,7 +18,7 @@ ## Rollout 1. Implement the creation-time reader and handle terminate with hermetic tests. -2. Implement `stopWindowsServiceChecked` and wire all four callers from 030, including the updater variant. +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) and one elevated-task setup; attach redacted evidence. 4. Desktop text for the additive fields in the same release. 5. Consider C for newly generated wrappers afterwards. From 22265f421b7299c714cb8603b60abeb021bc7696 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:22:36 +0900 Subject: [PATCH 06/14] docs(devlog): plain ocx stop never stops the child after a failed part A; one deadline for the consented stop --- .../261111_windows_service_stop/030_design.md | 46 +++++++++++++------ .../040_verification.md | 7 ++- .../050_rollout_risks.md | 2 +- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 861e07c59c3..2ab3e491bac 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -7,6 +7,7 @@ The proxy child is stopped by each caller AFTER the service stop, and `/api/stop - 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 `stopped` / `survived` / `denied` / `unknown` for the manager and wrappers only. - 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` 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 @@ -17,8 +18,8 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `ocx service stop` calls part A instead of `stopWindows`, stops the tracked proxy as today, then runs part B. - `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. -- `/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. Without a receipt the existing `respawnable_service` refusal stays. This keeps the route well inside the parent's 10 s POST abort, after which the parent falls back to a forced kill. -- 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. +- `/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` writes the receipt, and it sends the POST only after its own part A returned `stopped` (see "Two parts"), so skipping is safe by construction. Without a receipt the existing `respawnable_service` refusal stays for a respawnable Task Scheduler service. Where the respawn risk is `none` (WinSW, or a deleted task with a stray wrapper) the route runs part A in process with a 6 s cap before it answers; the drain runs after the answer, so the answer stays under the parent's 10 s POST abort, after which the parent would fall back to a forced kill. A part A overrun there 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 only through the consented `ocx stop` child; the #5443 boundary is unchanged and Desktop never elevates. @@ -28,14 +29,14 @@ 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 its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), 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. Ending a hand-started copy is left to the operator by design. 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. +3. Lineage: it is an ancestor of the identity-checked child (`readPid()` identity), or a descendant of a BOUND wrapper, or its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), 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. A hand-started launcher whose own shell has already exited does satisfy "parent is gone" and is bound; that is accepted, because it carries this home's paths and would otherwise keep relaunching this home's proxy. 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. Anything the rule cannot bind stays `unknown` → `approval-changed`, and Desktop shows the existing refusal with the new `unbound-wrapper` / `evidence-unreadable` reason. +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 @@ -52,20 +53,35 @@ New work, named explicitly (x64): the creation-time reader, the handle terminate Part A: 3. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). 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. -4. Wait until `observeWindowsGuardedManagerStopped` = inactive and no bound wrapper is alive (part A deadline: 5 s, starting after the manager stop returns). +4. 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 (step 5), where a late relaunch is what it has to catch. Part A therefore costs one bind enumeration (shared with the guarded `revalidateManager`, so no second CIM query on that path) + `/end` (cap 2 s) + 3 s + 1 s. -## Time budget per caller (worst case) +## Time budget (one deadline) -| Caller | Part A | Child stop | Part B | Bound to fit | -|---|---|---|---|---| -| `ocx stop` (Desktop consented) | bind + `/end` + terminate + 5 s | `/api/stop` graceful drain (`shutdownTimeoutMs` + 3 s), route skips part A | 12 s | Desktop's 30 s whole-stop limit: part A 8 s (one CIM enumeration up front, then process handles only during the wait) + drain 8 s (default `shutdownTimeoutMs` 5 s + 3 s) + part B 12 s = 28 s. With a configured `shutdownTimeoutMs` above 7 s, part B gets only the remaining time, never less than one 6 s relaunch period; if even that does not fit, the stop reports `survived` with reason `unverified-budget` rather than overrun | -| `/api/stop` route | skipped with a receipt | its own drain | none | parent's 10 s POST abort | -| `ocx service stop`, `ocx uninstall`, service uninstall, install/switch | 8 s | tracked-proxy stop | 12 s | interactive, no fixed external limit | -| updater | 8 s | its own | its own reclaim | update job budget | +Every caller runs against ONE deadline that starts when the command begins, with a fixed reserve held back for the shared restore and the JSON summary. Each step takes the smaller of its own cap and the time left before the reserve. The graceful drain is never shortened (shortening it would turn a graceful stop into today's forced fallback); part B takes what remains, never less than its 6 s floor (one relaunch period). If the floor does not fit, the stop does not overrun: it reports `manager-still-active` (guarded) or `failed` (snapshotless) with `serviceStopReason: "unverified-budget"`, and skips the shared teardown. + +Consented `ocx stop` (the Desktop path, `--expect-*`): deadline 27 s from process start, reserve 2 s, inside Desktop's 30 s `DEADLINE` (the remaining 3 s cover process spawn and parsing). Worst case with default settings, every step at its cap: + +| Step (existing steps included) | Cap | Running total | +|---|---|---| +| re-resolve + `revalidateManager` (one CIM enumeration, reused as part A's bind) | 5 s | 5 s | +| part A: `/end` 2 s + handle wait on bound wrappers 3 s + manager state (`schtasks`/`sc` only) 1 s | 6 s | 11 s | +| `signalApproved` → `stopProxy`: POST answer + drain (`shutdownTimeoutMs` 5 s + 3 s), never shortened; bounded by the 10 s POST abort | 8 s | 19 s | +| `settle` + part B merged: `settle`'s approved-PID exit (`waitExit`) and endpoint probe are part B's first sample; `waitForStoppedPort` inside `stopProxy` is the same predicate and counts here; 6 s hold | 6 s floor, up to `min(12 s, remaining)` | 25 s | +| final observation: `managerState` and `guardFinalStopSummary` share ONE `observeWindowsGuardedManagerStopped` call (the CIM enumeration that catches a relaunched wrapper), started at the beginning of the hold and run concurrently with it, cap 5 s < 6 s hold | 0 s added | 25 s | +| reserve: shared restore + summary | 2 s | 27 s | + +With a configured `shutdownTimeoutMs` above 5 s the drain grows (up to the 10 s POST abort), part B falls below its floor in the worst case, and the stop reports `unverified-budget` instead of overrunning; it still succeeds whenever earlier steps finished faster than their caps. + +| Caller | Deadline | Part B window | +|---|---|---| +| `ocx stop` consented (Desktop) | 27 s, table above | 6–12 s | +| `ocx stop` snapshotless, `ocx service stop`, `ocx uninstall`, service uninstall/remove, install/switch | interactive: 60 s, same accounting | 12 s | +| `/api/stop` route | receipt: none (part A skipped); no receipt and risk `none`: part A 6 s before the answer | none (the parent's) | +| updater | update job budget, same accounting | its own reclaim checks | Part B (caller, after its graceful child stop): -5. The listening port is free and stays free for one relaunch period (6 s hold), within a 12 s budget that starts when the caller's child stop returns, so a loop that relaunches late is caught. 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. +5. 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 during the hold and must show no wrapper-image process with this home's path token; 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. 6. Report (part A outcome combined with part B). ## Outcomes and the output contract @@ -73,12 +89,12 @@ Part B (caller, after its graceful child stop): | Operation outcome | `ServiceStopOutcome` | `ocx-stop/1`, snapshotless path | `ocx-stop/1`, guarded (consent) path | `/api/stop` | Desktop | |---|---|---|---|---|---| | stopped | `stopped` | `outcome: stopped`, `service: stopped` | `outcome: stopped` | 200 | stopped | -| survived (bound wrapper or this installation's identity still on the port) | `failed` | `outcome: failed`, `proxy: respawned` (existing values) | `outcome: manager-still-active` (existing) | existing failure code | existing terminal handling | +| 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` | additive `portHeldByForeign: true` | additive field | warning text | -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. +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 diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index a510c1e0084..26a4d99317a 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -18,6 +18,11 @@ - `/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`. +- Budget, with injected clocks: every guarded step at its cap still fits 27 s with default settings; a larger `shutdownTimeoutMs` keeps the full drain and ends in `unverified-budget` with no shared teardown, never past the deadline; part B never gets less than its 6 s floor. +- A wrapper relaunched after bind (port still free during the hold) is caught by the final enumeration → survived. +- `/api/stop` without a receipt and risk `none` answers within its 6 s part A cap; an overrun answers `service_state_unknown`. +- 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) @@ -29,7 +34,7 @@ | 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 part A (5 s wait) and part B (6 s hold) deadlines | Real SCM behavior | +| WinSW backend stop with `onfailure restart delay="5 sec"` against the part A (3 s handle wait) and part B (6 s hold) deadlines | 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) | 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 c3530013122..cabafc60aa3 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -5,7 +5,7 @@ - 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. +- 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. - 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. From 530b5484f84c9103c95888c5059f2919e5f14b69 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:28:25 +0900 Subject: [PATCH 07/14] docs(devlog): absent outcome for part A; admission-checked deadline passed by each Desktop path --- .../261111_windows_service_stop/030_design.md | 45 ++++++++++--------- .../040_verification.md | 5 ++- .../050_rollout_risks.md | 2 + 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 2ab3e491bac..fdd8f33baed 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -4,10 +4,10 @@ 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 `stopped` / `survived` / `denied` / `unknown` for the manager and wrappers only. +- 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. - 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` 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`. +- 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 @@ -18,10 +18,10 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `ocx service stop` calls part A instead of `stopWindows`, stops the tracked proxy as today, then runs part B. - `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. -- `/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` writes the receipt, and it sends the POST only after its own part A returned `stopped` (see "Two parts"), so skipping is safe by construction. Without a receipt the existing `respawnable_service` refusal stays for a respawnable Task Scheduler service. Where the respawn risk is `none` (WinSW, or a deleted task with a stray wrapper) the route runs part A in process with a 6 s cap before it answers; the drain runs after the answer, so the answer stays under the parent's 10 s POST abort, after which the parent would fall back to a forced kill. A part A overrun there answers `service_state_unknown` and changes nothing further. +- `/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` writes the receipt, and it sends the POST only after its own part A returned `stopped` or `absent` (see "Two parts"), so skipping is safe by construction. Without a receipt the existing `respawnable_service` refusal stays for a respawnable Task Scheduler service. Where the respawn risk is `none` (WinSW, or a deleted task with a stray wrapper) the route runs part A in process 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. The implementation must measure the teardown's worst case and keep part A plus teardown under the parent's 10 s POST abort, after which the parent would fall back to a forced kill; 040 carries that as a test. A part A overrun there 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 only through the consented `ocx stop` child; the #5443 boundary is unchanged and Desktop never elevates. +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) @@ -55,29 +55,33 @@ Part A: 3. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). 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. 4. 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 (step 5), where a late relaunch is what it has to catch. Part A therefore costs one bind enumeration (shared with the guarded `revalidateManager`, so no second CIM query on that path) + `/end` (cap 2 s) + 3 s + 1 s. -## Time budget (one deadline) +## Time budget (one deadline, checked before anything changes) -Every caller runs against ONE deadline that starts when the command begins, with a fixed reserve held back for the shared restore and the JSON summary. Each step takes the smaller of its own cap and the time left before the reserve. The graceful drain is never shortened (shortening it would turn a graceful stop into today's forced fallback); part B takes what remains, never less than its 6 s floor (one relaunch period). If the floor does not fit, the stop does not overrun: it reports `manager-still-active` (guarded) or `failed` (snapshotless) with `serviceStopReason: "unverified-budget"`, and skips the shared teardown. +Windows only. The async `stopManager` change reaches every platform, but macOS and Linux keep today's timing and ignore the deadline. -Consented `ocx stop` (the Desktop path, `--expect-*`): deadline 27 s from process start, reserve 2 s, inside Desktop's 30 s `DEADLINE` (the remaining 3 s cover process spawn and parsing). Worst case with default settings, every step at its cap: +`ocx stop` takes the deadline as an input, `--deadline-ms `, measured from its own process start. Desktop passes it on both paths: the remaining startup deadline for the consented takeover, and `runtime_stop::DEADLINE` minus a 3 s spawn/parse margin for the exit drain. 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. -| Step (existing steps included) | Cap | Running total | +Admission. Right before part A, 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, they are not given a cap of their own), the stop computes the worst case of everything still to come. If the remaining time cannot hold it, the stop changes NOTHING (no `/end`, no terminate, no POST) and reports `serviceStopReason: "unverified-budget"` with `manager-still-active` (guarded) or `failed` (snapshotless). This replaces "report afterwards": once part A has started, every remaining step has a cap and their sum fits by construction, so the drain is never shortened and the stop never overruns. + +Worst case still to come, at admission: + +| Step | Worst case | Notes | |---|---|---| -| re-resolve + `revalidateManager` (one CIM enumeration, reused as part A's bind) | 5 s | 5 s | -| part A: `/end` 2 s + handle wait on bound wrappers 3 s + manager state (`schtasks`/`sc` only) 1 s | 6 s | 11 s | -| `signalApproved` → `stopProxy`: POST answer + drain (`shutdownTimeoutMs` 5 s + 3 s), never shortened; bounded by the 10 s POST abort | 8 s | 19 s | -| `settle` + part B merged: `settle`'s approved-PID exit (`waitExit`) and endpoint probe are part B's first sample; `waitForStoppedPort` inside `stopProxy` is the same predicate and counts here; 6 s hold | 6 s floor, up to `min(12 s, remaining)` | 25 s | -| final observation: `managerState` and `guardFinalStopSummary` share ONE `observeWindowsGuardedManagerStopped` call (the CIM enumeration that catches a relaunched wrapper), started at the beginning of the hold and run concurrently with it, cap 5 s < 6 s hold | 0 s added | 25 s | -| reserve: shared restore + summary | 2 s | 27 s | +| part A | 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) | `absent` returns after the enumeration | +| POST answer | `min(10 s, remaining − the rows below)`, floor 3 s | with a receipt the route skips part A and defers the teardown, so it answers at once; the 10 s abort only matters for a hung proxy. An abort falls back to today's forced kill | +| graceful exit wait | `shutdownTimeoutMs` + 3 s (8 s by default), never shortened | runs after the POST answer, as `stopProxyGracefullyDetailed` does today | +| part B | 6 s floor, up to `min(12 s, remaining)` | `waitForStoppedPort` (today a separate 15 s wait inside `stopProxy`, also after a forced kill) is the same port-free predicate and on Windows becomes part B's first phase instead of a separate wait; `settle`'s `waitExit` and endpoint probe are its first sample; the single final CIM enumeration (`managerState` and `guardFinalStopSummary` share it) runs concurrently with the hold, capped 5 s < 6 s | +| reserve | 2 s | shared restore and the JSON summary | -With a configured `shutdownTimeoutMs` above 5 s the drain grows (up to the 10 s POST abort), part B falls below its floor in the worst case, and the stop reports `unverified-budget` instead of overrunning; it still succeeds whenever earlier steps finished faster than their caps. +Admission minimum with default settings: guarded 6 + 3 + 8 + 6 + 2 = 25 s; snapshotless 11 + 3 + 8 + 6 + 2 = 30 s. -| Caller | Deadline | Part B window | +| Caller | Deadline | Fits? | |---|---|---| -| `ocx stop` consented (Desktop) | 27 s, table above | 6–12 s | -| `ocx stop` snapshotless, `ocx service stop`, `ocx uninstall`, service uninstall/remove, install/switch | interactive: 60 s, same accounting | 12 s | -| `/api/stop` route | receipt: none (part A skipped); no receipt and risk `none`: part A 6 s before the answer | none (the parent's) | -| updater | update job budget, same accounting | its own reclaim checks | +| consented takeover (`--expect-*`) | remaining startup deadline (Windows approval adds 60 s) | yes in practice; otherwise refused before anything changes | +| Desktop exit/restart drain (plain `ocx stop --json`) | `runtime_stop::DEADLINE` − 3 s = 27 s today | NO for a registered service in the worst case (30 s needed). The same change raises Desktop's exit-drain deadline on Windows to 45 s (42 s passed as `--deadline-ms`). 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: no part A; no receipt, risk `none`: part A 4 s + teardown before the answer | parent's 10 s POST abort | +| updater | job budget | yes | Part B (caller, after its graceful child stop): @@ -88,6 +92,7 @@ Part B (caller, after its graceful child stop): | 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 | diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index 26a4d99317a..9c8d983455c 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -19,9 +19,10 @@ - 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`. -- Budget, with injected clocks: every guarded step at its cap still fits 27 s with default settings; a larger `shutdownTimeoutMs` keeps the full drain and ends in `unverified-budget` with no shared teardown, never past the deadline; part B never gets less than its 6 s floor. +- `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: part A (4 s cap) plus the measured worst case of `performStopTeardown` answers within 10 s. - A wrapper relaunched after bind (port still free during the hold) is caught by the final enumeration → survived. -- `/api/stop` without a receipt and risk `none` answers within its 6 s part A cap; an overrun answers `service_state_unknown`. - 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). 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 cabafc60aa3..3e6743c392b 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -7,6 +7,8 @@ - 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 45 s on Windows and passes `--deadline-ms`. 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 From bb02c850d51198bebaa7cd4da55a5f4083e6973e Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:30:57 +0900 Subject: [PATCH 08/14] docs(devlog): receipt records part A; bind orphans only under a registered task; foreign port stops install; ARM64 device --- devlog/_plan/261111_windows_service_stop/030_design.md | 8 ++++---- .../_plan/261111_windows_service_stop/040_verification.md | 4 +++- .../261111_windows_service_stop/050_rollout_risks.md | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index fdd8f33baed..3dedc2d4b6f 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -17,8 +17,8 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `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`, stops the tracked proxy as today, then runs part B. - `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. -- `/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` writes the receipt, and it sends the POST only after its own part A returned `stopped` or `absent` (see "Two parts"), so skipping is safe by construction. Without a receipt the existing `respawnable_service` refusal stays for a respawnable Task Scheduler service. Where the respawn risk is `none` (WinSW, or a deleted task with a stray wrapper) the route runs part A in process 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. The implementation must measure the teardown's worst case and keep part A plus teardown under the parent's 10 s POST abort, after which the parent would fall back to a forced kill; 040 carries that as a test. A part A overrun there answers `service_state_unknown` and changes nothing further. +- `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` writes the receipt, and it 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` (WinSW, or a deleted task with a stray wrapper) the route runs part A in process 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. The implementation must measure the teardown's worst case and keep part A plus teardown under the parent's 10 s POST abort, after which the parent would fall back to a forced kill; 040 carries that as a test. A part A overrun there 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. @@ -29,7 +29,7 @@ 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 its parent is gone (a parent PID whose creation time is later than the wrapper's counts as gone), 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. A hand-started launcher whose own shell has already exited does satisfy "parent is gone" and is bound; that is accepted, because it carries this home's paths and would otherwise keep relaunching this home's proxy. 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. +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. @@ -97,7 +97,7 @@ Part B (caller, after its graceful child stop): | 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` | additive `portHeldByForeign: true` | additive field | warning text | +| 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. diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index 9c8d983455c..e813d0712ce 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -10,7 +10,9 @@ - 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; `/api/stop` runs part A only. +- 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. 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 3e6743c392b..6e3c1b3b502 100644 --- a/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md +++ b/devlog/_plan/261111_windows_service_stop/050_rollout_risks.md @@ -21,6 +21,6 @@ 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) and one elevated-task setup; attach redacted evidence. +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. From 6e575f2c944e6527a9c26a4a69e27071dc775488 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:34:09 +0900 Subject: [PATCH 09/14] docs(devlog): cap every post-admission step, budget WinSW and the registration probe, keep a 10 s POST without a receipt --- .../261111_windows_service_stop/030_design.md | 42 ++++++++++--------- .../040_verification.md | 7 +++- .../050_rollout_risks.md | 2 +- 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 3dedc2d4b6f..2b854b0aa5b 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -4,7 +4,7 @@ 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. +- 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`. @@ -18,7 +18,7 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `ocx service stop` calls part A instead of `stopWindows`, stops the tracked proxy as today, then runs part B. - `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` writes the receipt, and it 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` (WinSW, or a deleted task with a stray wrapper) the route runs part A in process 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. The implementation must measure the teardown's worst case and keep part A plus teardown under the parent's 10 s POST abort, after which the parent would fall back to a forced kill; 040 carries that as a test. A part A overrun there answers `service_state_unknown` and changes nothing further. +- `/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` writes the receipt, and it 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. A parent POST without a receipt (receipt writing failed, or the connected-client path) always gets the full 10 s abort, never a shortened one, and admission 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. @@ -48,45 +48,49 @@ New work, named explicitly (x64): the creation-time reader, the handle terminate ## Steps -1. Bind (rule above) and record (pid, startTime, image, command-line digest). -2. Ask the manager: `schtasks /end` / WinSW stop. Access denied → `denied`, stop there. Part A: -3. Terminate bound wrapper survivors through the handle primitive (`cmd.exe` running the loop first, then the launcher). 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. -4. 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 (step 5), where a late relaunch is what it has to catch. Part A therefore costs one bind enumeration (shared with the guarded `revalidateManager`, so no second CIM query on that path) + `/end` (cap 2 s) + 3 s + 1 s. +1. Probe registration: `probeWindowsSchedulerTask` and, for WinSW, the service status (both capped at 2 s; an overrun or unreadable answer is `unknown`, nothing changed). 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 followed by a capped SCM state poll (`sc query`) instead of today's `stopwait`; 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: the remaining startup deadline for the consented takeover, and `runtime_stop::DEADLINE` minus a 3 s spawn/parse margin for the exit drain. 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 `, 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. Right before part A, 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, they are not given a cap of their own), the stop computes the worst case of everything still to come. If the remaining time cannot hold it, the stop changes NOTHING (no `/end`, no terminate, no POST) and reports `serviceStopReason: "unverified-budget"` with `manager-still-active` (guarded) or `failed` (snapshotless). This replaces "report afterwards": once part A has started, every remaining step has a cap and their sum fits by construction, so the drain is never shortened and the stop never overruns. +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 | 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) | `absent` returns after the enumeration | -| POST answer | `min(10 s, remaining − the rows below)`, floor 3 s | with a receipt the route skips part A and defers the teardown, so it answers at once; the 10 s abort only matters for a hung proxy. An abort falls back to today's forced kill | -| graceful exit wait | `shutdownTimeoutMs` + 3 s (8 s by default), never shortened | runs after the POST answer, as `stopProxyGracefullyDetailed` does today | -| part B | 6 s floor, up to `min(12 s, remaining)` | `waitForStoppedPort` (today a separate 15 s wait inside `stopProxy`, also after a forced kill) is the same port-free predicate and on Windows becomes part B's first phase instead of a separate wait; `settle`'s `waitExit` and endpoint probe are its first sample; the single final CIM enumeration (`managerState` and `guardFinalStopSummary` share it) runs concurrently with the hold, capped 5 s < 6 s | -| reserve | 2 s | shared restore and the JSON summary | +| 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 | `` + 2 s = 22 s (+ 5 s enumeration snapshotless, for a stray of a deleted task) | WinSW stops the proxy itself, so the POST and exit-wait rows below do not apply to it | +| orphan discovery (snapshotless, no PID file) | 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 | with a part A record in the receipt: `min(10 s, remaining − the rows below)`, floor 3 s; without a receipt, or without the record: 10 s | with the record the route skips part A and defers the teardown, so it answers at once | +| child exit: graceful or forced, whichever is longer | `max(shutdownTimeoutMs + 3 s, 10 s)` = 10 s by default | graceful: the exit wait, never shortened. Forced (after a POST abort, never after a refusal): `taskkill` gets a 5 s timeout (it has none today) plus today's `waitForExit` 5 s | +| inherited-receipt probes | 1.5 s × N | N abandoned receipts, known at admission | +| part B | 6 s floor, up to `min(12 s, remaining)` | `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 concurrently with the hold, capped 5 s < 6 s. Not run after a guarded `absent` manager (where `stopManager` is not called, as today) | +| reserve | 3 s | shared restore, `revertSystemEnv` (capped at 1 s; an overrun is logged and left for the next start), and the JSON summary | -Admission minimum with default settings: guarded 6 + 3 + 8 + 6 + 2 = 25 s; snapshotless 11 + 3 + 8 + 6 + 2 = 30 s. +Admission minimum with default settings and N = 0: guarded Task Scheduler 6 + 3 + 10 + 6 + 3 = 28 s; snapshotless Task Scheduler with a receipt 11 + 3 + 3 + 10 + 6 + 3 = 36 s, without one 11 + 3 + 10 + 10 + 6 + 3 = 43 s; WinSW 22 (+5) + 6 + 3 = 31–36 s. | Caller | Deadline | Fits? | |---|---|---| -| consented takeover (`--expect-*`) | remaining startup deadline (Windows approval adds 60 s) | yes in practice; otherwise refused before anything changes | -| Desktop exit/restart drain (plain `ocx stop --json`) | `runtime_stop::DEADLINE` − 3 s = 27 s today | NO for a registered service in the worst case (30 s needed). The same change raises Desktop's exit-drain deadline on Windows to 45 s (42 s passed as `--deadline-ms`). 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 | +| 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 50 s (47 s passed as `--deadline-ms`): every case above fits with up to two inherited receipts (one without a receipt of its own); 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: no part A; no receipt, risk `none`: part A 4 s + teardown before the answer | parent's 10 s POST abort | +| `/api/stop` route | receipt with a part A record: no part A; WinSW: today's in-process stop; stray of a deleted task, no receipt: 4 s sweep + teardown before the answer | the parent's 10 s POST abort, never shortened without a record | | updater | job budget | yes | Part B (caller, after its graceful child stop): -5. 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 during the hold and must show no wrapper-image process with this home's path token; 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. -6. Report (part A outcome combined with part B). +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 during the hold and must show no wrapper-image process with this home's path token; 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 diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index e813d0712ce..dbd55d91056 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -23,7 +23,10 @@ - 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: part A (4 s cap) plus the measured worst case of `performStopTeardown` answers within 10 s. +- `/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; a parent POST without a receipt or without a part A record is never given less than 10 s; 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`. +- 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 1.5 s × N at admission. +- 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). @@ -37,7 +40,7 @@ | 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 part A (3 s handle wait) and part B (6 s hold) deadlines | Real SCM behavior | +| 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) | 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 6e3c1b3b502..05b27092b38 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 45 s on Windows and passes `--deadline-ms`. 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 50 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 50 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. From ae66038e8365b982a24ee44dfd6df5c44e1d2ea3 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:37:47 +0900 Subject: [PATCH 10/14] docs(devlog): final enumeration after the hold, counted; service stop defers teardown through a receipt --- devlog/_plan/261111_windows_service_stop/030_design.md | 10 +++++----- .../261111_windows_service_stop/040_verification.md | 1 + .../261111_windows_service_stop/050_rollout_risks.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 2b854b0aa5b..ef681166805 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -15,7 +15,7 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `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`, stops the tracked proxy as today, then runs part B. +- `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 it degrades the way `ocx stop` already does, and only after a part A `stopped`, so no wrapper can relaunch the proxy under the teardown. - `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` writes the receipt, and it 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. A parent POST without a receipt (receipt writing failed, or the connected-client path) always gets the full 10 s abort, never a shortened one, and admission 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. @@ -74,22 +74,22 @@ Worst case still to come, at admission: | POST answer | with a part A record in the receipt: `min(10 s, remaining − the rows below)`, floor 3 s; without a receipt, or without the record: 10 s | with the record the route skips part A and defers the teardown, so it answers at once | | child exit: graceful or forced, whichever is longer | `max(shutdownTimeoutMs + 3 s, 10 s)` = 10 s by default | graceful: the exit wait, never shortened. Forced (after a POST abort, never after a refusal): `taskkill` gets a 5 s timeout (it has none today) plus today's `waitForExit` 5 s | | inherited-receipt probes | 1.5 s × N | N abandoned receipts, known at admission | -| part B | 6 s floor, up to `min(12 s, remaining)` | `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 concurrently with the hold, capped 5 s < 6 s. Not run after a guarded `absent` manager (where `stopManager` is not called, as today) | +| part B | 6 s floor, up to `min(12 s, remaining)` | `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) | | reserve | 3 s | shared restore, `revertSystemEnv` (capped at 1 s; 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 + 3 + 10 + 6 + 3 = 28 s; snapshotless Task Scheduler with a receipt 11 + 3 + 3 + 10 + 6 + 3 = 36 s, without one 11 + 3 + 10 + 10 + 6 + 3 = 43 s; WinSW 22 (+5) + 6 + 3 = 31–36 s. +Admission minimum with default settings and N = 0: guarded Task Scheduler 6 + 3 + 10 + 11 + 3 = 33 s; snapshotless Task Scheduler with a receipt 11 + 3 + 3 + 10 + 11 + 3 = 41 s, without one 11 + 3 + 10 + 10 + 11 + 3 = 48 s; WinSW 22 (+5) + 11 + 3 = 36–41 s. | 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 50 s (47 s passed as `--deadline-ms`): every case above fits with up to two inherited receipts (one without a receipt of its own); 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 55 s (52 s passed as `--deadline-ms`): every case above fits with up to two inherited receipts; 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; WinSW: today's in-process stop; stray of a deleted task, no receipt: 4 s sweep + teardown before the answer | the parent's 10 s POST abort, never shortened without a record | | 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 during the hold and must show no wrapper-image process with this home's path token; 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 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 diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index dbd55d91056..093adbfee71 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -25,6 +25,7 @@ - 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; a parent POST without a receipt or without a part A record is never given less than 10 s; 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. - 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 1.5 s × N at admission. - 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. 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 05b27092b38..64474a3e9c3 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 50 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 50 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 55 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 55 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. From b14978f356628ebe02b53c1213a121cf1d971279 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:39:06 +0900 Subject: [PATCH 11/14] docs(devlog): budget the POST at 10 s always and the exit ladder in sequence; Desktop exit drain 65 s --- .../261111_windows_service_stop/030_design.md | 18 +++++++++--------- .../040_verification.md | 3 ++- .../050_rollout_risks.md | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index ef681166805..261e466c9c9 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -18,7 +18,7 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `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 it degrades the way `ocx stop` already does, and only after a part A `stopped`, so no wrapper can relaunch the proxy under the teardown. - `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` writes the receipt, and it 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. A parent POST without a receipt (receipt writing failed, or the connected-client path) always gets the full 10 s abort, never a shortened one, and admission 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. +- `/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` writes the receipt, and it 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. @@ -50,9 +50,9 @@ New work, named explicitly (x64): the creation-time reader, the handle terminate 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). Nothing registered and nothing bound → `absent`, return. +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 followed by a capped SCM state poll (`sc query`) instead of today's `stopwait`; 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). +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. @@ -70,21 +70,21 @@ Worst case still to come, at admission: |---|---|---| | 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 | `` + 2 s = 22 s (+ 5 s enumeration snapshotless, for a stray of a deleted task) | WinSW stops the proxy itself, so the POST and exit-wait rows below do not apply to it | -| orphan discovery (snapshotless, no PID file) | 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 | with a part A record in the receipt: `min(10 s, remaining − the rows below)`, floor 3 s; without a receipt, or without the record: 10 s | with the record the route skips part A and defers the teardown, so it answers at once | -| child exit: graceful or forced, whichever is longer | `max(shutdownTimeoutMs + 3 s, 10 s)` = 10 s by default | graceful: the exit wait, never shortened. Forced (after a POST abort, never after a refusal): `taskkill` gets a 5 s timeout (it has none today) plus today's `waitForExit` 5 s | +| 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 | 1.5 s × N | N abandoned receipts, known at admission | | part B | 6 s floor, up to `min(12 s, remaining)` | `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) | | reserve | 3 s | shared restore, `revertSystemEnv` (capped at 1 s; 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 + 3 + 10 + 11 + 3 = 33 s; snapshotless Task Scheduler with a receipt 11 + 3 + 3 + 10 + 11 + 3 = 41 s, without one 11 + 3 + 10 + 10 + 11 + 3 = 48 s; WinSW 22 (+5) + 11 + 3 = 36–41 s. +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 22 + 3 + 11 + 3 = 39 s (+5 s for the stray enumeration of a deleted task, which runs before 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 55 s (52 s passed as `--deadline-ms`): every case above fits with up to two inherited receipts; 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 65 s (62 s passed as `--deadline-ms`): every case above fits with up to four inherited receipts (56 + 1.5 × 4 = 62 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; WinSW: today's in-process stop; stray of a deleted task, no receipt: 4 s sweep + teardown before the answer | the parent's 10 s POST abort, never shortened without a record | +| `/api/stop` route | receipt with a part A record: no part A; 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): diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index 093adbfee71..3443a4806bf 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -23,9 +23,10 @@ - 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; a parent POST without a receipt or without a part A record is never given less than 10 s; a registered WinSW service keeps today's in-process stop. +- `/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. +- 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 1.5 s × N at admission. - 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. 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 64474a3e9c3..f72ce61faa9 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 55 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 55 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 65 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 65 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. From 9a73b09152c06feb495e7286f0bdc1bca9bd49a0 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:41:09 +0900 Subject: [PATCH 12/14] docs(devlog): service stop sends no undeferred child stop when the receipt claim fails --- devlog/_plan/261111_windows_service_stop/030_design.md | 4 ++-- devlog/_plan/261111_windows_service_stop/040_verification.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 261e466c9c9..87c083f49fe 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -15,10 +15,10 @@ Part A replaces, on Windows only, the "manager stop + best-effort wrapper kill" - `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 it degrades the way `ocx stop` already does, and only after a part A `stopped`, so no wrapper can relaunch the proxy under the teardown. +- `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` writes the receipt, and it 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. +- `/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. diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index 3443a4806bf..67338761811 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -25,7 +25,7 @@ - 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. +- 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 1.5 s × N at admission. - Admission runs after the registration probe: `absent` is never refused for budget. From 9436dbc910f6c7cf12d94602f9f37c7b7ef673e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:45:25 +0900 Subject: [PATCH 13/14] docs(devlog): correct WinSW and receipt-probe arithmetic; one place for the stray enumeration --- .../261111_windows_service_stop/030_design.md | 14 +++++++------- .../040_verification.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 87c083f49fe..5b57318018f 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -69,22 +69,22 @@ 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 | `` + 2 s = 22 s (+ 5 s enumeration snapshotless, for a stray of a deleted task) | WinSW stops the proxy itself, so the POST and exit-wait rows below do not apply to it | +| part A, WinSW | 25 s: stop command 2 s + SCM poll `` + 2 s = 22 s + manager state 1 s | (a stray of a deleted task is looked for in step 1, before admission, and is not counted here) | WinSW stops the proxy itself, so the POST and exit-wait rows below do not apply to it | | 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 | 1.5 s × N | N abandoned receipts, known at admission | -| part B | 6 s floor, up to `min(12 s, remaining)` | `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) | -| reserve | 3 s | shared restore, `revertSystemEnv` (capped at 1 s; an overrun is logged and left for the next start), and the JSON summary | +| 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 22 + 3 + 11 + 3 = 39 s (+5 s for the stray enumeration of a deleted task, which runs before admission). +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 65 s (62 s passed as `--deadline-ms`): every case above fits with up to four inherited receipts (56 + 1.5 × 4 = 62 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 65 s (62 s passed as `--deadline-ms`): every case above fits with up to two inherited receipts (56 + 3 × 2 = 62 s, less what the CLI start and the 2 s registration probe already used); 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; 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 | +| `/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): diff --git a/devlog/_plan/261111_windows_service_stop/040_verification.md b/devlog/_plan/261111_windows_service_stop/040_verification.md index 67338761811..7bc683508b9 100644 --- a/devlog/_plan/261111_windows_service_stop/040_verification.md +++ b/devlog/_plan/261111_windows_service_stop/040_verification.md @@ -27,7 +27,7 @@ - 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 1.5 s × N at admission. +- 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. From 9a6c94d73d346d049a291f7877af4bb8b23a888d Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 11 Nov 2026 21:48:04 +0900 Subject: [PATCH 14/14] docs(devlog): three-cell WinSW row; Desktop exit drain 75 s with a pre-admission allowance --- devlog/_plan/261111_windows_service_stop/030_design.md | 4 ++-- devlog/_plan/261111_windows_service_stop/050_rollout_risks.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/devlog/_plan/261111_windows_service_stop/030_design.md b/devlog/_plan/261111_windows_service_stop/030_design.md index 5b57318018f..8506553311f 100644 --- a/devlog/_plan/261111_windows_service_stop/030_design.md +++ b/devlog/_plan/261111_windows_service_stop/030_design.md @@ -69,7 +69,7 @@ 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 | (a stray of a deleted task is looked for in step 1, before admission, and is not counted here) | WinSW stops the proxy itself, so the POST and exit-wait rows below do not apply to it | +| 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 | @@ -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 65 s (62 s passed as `--deadline-ms`): every case above fits with up to two inherited receipts (56 + 3 × 2 = 62 s, less what the CLI start and the 2 s registration probe already used); 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 `--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 f72ce61faa9..7d8349b0868 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 65 s on Windows and passes `--deadline-ms`. A configured `shutdownTimeoutMs` well above the default grows the exit-wait row; beyond what 65 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 `--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.