Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions devlog/_plan/261111_windows_service_stop/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 000 — Windows service stop that frees the port (#7771 Option B): design

Status: design only, awaiting owner approval. Nothing here is implemented.

## Problem

On Windows with the Task Scheduler backend, neither `ocx stop` nor `ocx service stop` can prove that the proxy port is free afterwards. `schtasks /end` ends the task instance but often leaves the wrapper (`wscript.exe` running the launcher VBS, then `cmd.exe` running the service script) alive, and the wrapper's `:loop` relaunches the proxy about five seconds later. Desktop then sits at `taking-over`, a second `ocx start` loses the port, and an update can race a respawned runtime. Issue #7771 tracks this; its 2026-10-28 design comment proposed options A (diagnostics), B (one checked stop) and C (a cooperative wrapper handshake) and recommended A first, then B.

What has shipped since: refusal diagnostics (#7973, option A), in-process command-line reads so the guarded takeover can verify the bundled runtime (#8487), and `ocx service restart` no longer reporting success while the old runtime answers (#8487). The port-freeing stop is what remains.

## Documents

| Doc | Content |
|---|---|
| 010 | Current stop paths, source-backed |
| 020 | Options and how they compare |
| 030 | Recommended design: one checked Windows stop |
| 040 | Verification: unit, hermetic integration, and real Windows device |
| 050 | Risks, compatibility and rollout |

## Decision requested

Approve 030 (recommended: option B split into part A, a checked manager-and-wrapper stop, and part B, the caller's port confirmation after its graceful child stop, used by every Windows stop path including service uninstall and install/backend switch: `ocx stop` with and without a consent snapshot, `ocx uninstall`, `/api/stop`, `ocx service stop` and the updater's Windows stop; an explicit binding rule including the stray-wrapper case; a new creation-time reader and handle-based terminate; outcomes mapped additively onto `ServiceStopOutcome`, `ocx-stop/1`, `/api/stop` and Desktop). Also decide the Windows ARM64 variant (recommended bounded fallback vs strict, 030/050). Implementation starts only after that approval and needs a Windows host for the acceptance checks in 040.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 010 — Current Windows stop paths (source at origin/dev, 2026-11-11)

## Wrapper respawn

`buildWindowsServiceScript` (src/service/windows-taskxml.ts) emits a `:loop` that relaunches Bun or `ocx.exe` about five seconds after any child exit other than the protocol exit 42 (src/service/windows-wrapper-exit.ts). The registered task also carries restart-on-failure (PT1M, 3 attempts) and logon/session-unlock triggers. Killing only the proxy child brings it back without any Task Scheduler involvement.

## Who calls which stop

| Caller | Manager stop | Wrapper cleanup | Post-stop check |
|---|---|---|---|
| `ocx stop` without a consent snapshot, `ocx uninstall`, dashboard/Desktop `/api/stop` | `stopServiceIfInstalledDetailed` (src/service/orchestration.ts): `stopWindowsChecked` when the task is present, WinSW stop, outcome `absent / stopped / stopped-respawnable / failed / state-unknown` | `killWindowsServiceWrapperProcesses` after the manager stop | `ocx stop` re-polls when the scheduler can respawn and reports `respawned`; `/api/stop` maps `state-unknown` to `service_state_unknown`, refuses a respawnable Task Scheduler service for a caller without a receipt (`respawnable_service`), and cannot observe its own post-exit window |
| `ocx stop` with a consent snapshot (Desktop's approved stop) | `runGuardedManagerStep` (src/cli/stop-approval.ts): revalidates the bound manager, stops it, signals the approved PID when the manager does not cascade (`childNeedsSeparateStop`, true for Task Scheduler and WinSW) | none of its own | `observeGuardedManagerStopped` → `inactive / active / unknown`; reports `manager-still-active` or `respawned` |
| `ocx service uninstall` / `remove` | `stopWindows` then task deletion | none | none (a looping wrapper can outlive its task) |
| Service install / backend switch (`prepareServiceInstall`) | manager stops for the recorded and requested backends | `killWindowsServiceWrapperProcesses` | its tracked-proxy stop |
| `ocx service stop` | `platformOps(...).stop` = `stopWindows` (swallows non-benign `/end` errors) | none | `stopTrackedProxyForServiceCommand` then `proxyStillLiveAfterStop` polls ~7 s and exits 1 on a survivor |
| Updater (Windows restart/reclaim) | `guardedStopWindows` / `guardedStopWinsw` (src/update/guarded-runtime-ops.ts, job-windows-restart.ts) | its own `killWindowsServiceWrapperProcesses` through `guardedKillWindowsWrappers` | its own reclaim checks |

So every path can already SEE a survivor (or at least a still-active manager); none can reliably FREE the port, because the wrapper cleanup (`killWindowsSchedulerWrappers`, src/lib/windows-service-wrappers.ts) is best effort: it matches this home's canonical launcher/script paths as complete command-line tokens, skips processes whose command line it cannot read, runs `Stop-Process -ErrorAction SilentlyContinue`, ignores the result and never re-observes.

## Existing building blocks

- `inspectWindowsGuardedManager` (src/service/windows-guarded-manager.ts) proves a manager owns an approved PID: the registered task must be a recognized OpenCodex layout (`windowsTaskRegistrationHealthy` or the exact legacy shape, via the `winRegistrationOurs` dependency), running, with an ancestor wrapper carrying this home's canonical paths; WinSW must be this installation's binary with the SCM PID as an ancestor. Unreadable evidence stays unknown. A surviving wrapper while the task reads not-running is `unknown("a surviving scheduler wrapper could not be tied to the registered task")`.
- `observeWindowsGuardedManagerStopped`: task not running, no wrapper carrying this home's paths, WinSW not started, no unreadable wrapper under the former manager.
- `readProcessCommandLine` reads one PID's command line in process on Windows x64 (#8487). Wrapper enumeration (`windowsProcessList`) still runs `Get-CimInstance Win32_Process`. `WindowsProcessEntry` carries pid, parentPid, name and commandLine, but no process start time.
10 changes: 10 additions & 0 deletions devlog/_plan/261111_windows_service_stop/020_options.md
Original file line number Diff line number Diff line change
@@ -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).
Loading
Loading