diff --git a/devlog/_plan/261111_windows_service_stop/000_plan.md b/devlog/_plan/261111_windows_service_stop/000_plan.md index 783cd00b0d5..58ade97dacd 100644 --- a/devlog/_plan/261111_windows_service_stop/000_plan.md +++ b/devlog/_plan/261111_windows_service_stop/000_plan.md @@ -1,6 +1,6 @@ # 000 — Windows service stop that frees the port (#7771 Option B): design -Status: design only, awaiting owner approval. Nothing here is implemented. +Status: approved (030, bounded ARM64 fallback). Implementation is split into three pull requests (060); the primitives and the two operations land first, unwired. ## Problem @@ -17,6 +17,7 @@ What has shipped since: refusal diagnostics (#7973, option A), in-process comman | 030 | Recommended design: one checked Windows stop | | 040 | Verification: unit, hermetic integration, and real Windows device | | 050 | Risks, compatibility and rollout | +| 060 | Implementation slices and where the code differs from 030 | ## Decision requested diff --git a/devlog/_plan/261111_windows_service_stop/060_implementation.md b/devlog/_plan/261111_windows_service_stop/060_implementation.md new file mode 100644 index 00000000000..85e153ad61d --- /dev/null +++ b/devlog/_plan/261111_windows_service_stop/060_implementation.md @@ -0,0 +1,62 @@ +# 060 — Implementation slices + +The owner approved 030 with the bounded ARM64 fallback. The work lands in three pull requests so each one is reviewable on its own, and only the second changes behavior. + +## PR1: primitives and the two operations (this slice) + +Library code with hermetic tests. Nothing calls it yet, so no platform's behavior changes. + +| Module | Content | +|---|---| +| `src/service/windows-process-native.ts` | x64 FFI from the trusted system directory: `GetProcessTimes` creation time, the handle terminate (open, compare creation time on that handle, `TerminateProcess`, close), the bind-time exit watch, `QueryServiceStatusEx` for the Schedule host PID | +| `src/service/windows-process-evidence.ts` | facades: the CIM snapshot with `CreationDate` (native times on x64 kept only when they agree with CIM to the ms), `sc.exe queryex Schedule` fallback, the ARM64 PowerShell terminate, the watch | +| `src/service/windows-stop-binding.ts` | the 030 binding rule as a pure function: bound (deepest first, pid + creation time + image + command-line digest), unbound, unreadable | +| `src/service/windows-stop-budget.ts` | the caps and the admission arithmetic of the 030 tables, plus the part B window | +| `src/service/windows-checked-stop.ts` | part A, `stopWindowsServiceChecked(deps, options)` with every effect injected, admission after the registration probe, and the authorizer variant `guardedStopWindowsServiceChecked` | +| `src/service/windows-stop-confirm.ts` | part B, `confirmWindowsStopFreed(deps)`, and `resolveWindowsStopPort` | + +Contract doc: `structure/ops/windows-checked-stop.md`. + +## PR2: caller wiring + +- Live deps adapters for part A and part B: capped `schtasks /query` and `/end` (2 s, through the test-home guard), WinSW non-waiting `stop` (2 s) and `sc query` polling, a port sampler, the listener identity check (`findLiveProxy` identity), and the clock. +- `stopServiceIfInstalledDetailed` and the guarded `stopManager` slot become async on every platform. On Windows they call part A instead of `stopWindowsChecked` plus `killWindowsServiceWrapperProcesses`, and all their callers change in the same PR. +- Part B replaces `proxyStillLiveAfterStop`, the `schedulerCanRespawn` re-poll and the uninstall respawn window on Windows, and is added after the guarded `settle`. +- `ocx stop --deadline-ms `, also read from `OCX_STOP_DEADLINE_MS` so a Desktop can pass a deadline to an older CLI without that CLI rejecting an unknown flag. The flag wins over the variable, and 60 s applies when neither is set. +- `ocx-stop/1` additive fields `serviceStopReason` and `portHeldByForeign`, with no new `outcome` or `proxy` value. +- The receipt's part A record (`partA`, the bound manager, and the ended wrapper set as pid + creation time), and `/api/stop` skipping part A for a receipt that carries it. +- The guarded inspection's `bound-stray` state through the binding rule, `sameGuardedManager` comparing the bound wrapper set. +- Service uninstall/remove and `prepareServiceInstall` refuse on a part A outcome other than `stopped`, and on a part B result other than a free port. +- The updater's three call sites move to the authorizer variant and abort on a non-`stopped` outcome (`preparePort` becomes async or part A runs before it). +- The new caps from 030: the forced `taskkill` at 5 s, orphan discovery at 3 s, and `revertSystemEnv` at 1 s. + +## PR3: Desktop + +- Optional `serviceStopReason` / `portHeldByForeign` fields on `StopSummary` (`serde(default)`), with the elevation hint and the warning text. +- The exit/restart drain deadline goes to 75 s on Windows, and the deadline passes through `OCX_STOP_DEADLINE_MS` (and `--deadline-ms` once the CLI floor allows it) on both the takeover and the drain. + +## Where PR1 differs from 030 + +- **Part B's closing sample.** The 6 s hold is measured between port sample starts, and the window floor is also 6 s, so the last sample starts at the window's end and can run up to its 0.5 s cap past it. The budget counts this, which raises the admission minimums to 48.5 / 56.5 / 42.5 s. The Desktop drain case becomes 70.5 s, which still fits in 72 s. +- **WinSW binds nothing.** WinSW-only part A neither enumerates nor binds. This follows 030's step 4 and its 25 s row: a stray scheduler wrapper left behind by an earlier backend is caught only when nothing is registered. "Proxy still alive after WinSW stopped" is an injected `proxyAlive` check inside the 1 s manager-state step. An answer of "cannot tell" leaves the decision to part B. +- **Terminates share the wait cap.** 030's tables do not price the terminate calls themselves. They share the 3 s handle-wait window, which matters for the ARM64 script (about 1 s each). +- **Unbound wrappers with nothing registered.** Binding finds no bound wrapper but an unbound one is alive. The result is `survived` / `unbound-wrapper`, not `absent`, matching 040's bullet; the 030 "absent" definition reads as "nothing bound" alone. Nothing is changed either way. +- **Unreadable candidates with nothing registered.** An unreadable wrapper image above the child counts only while a manager is registered. A hand-started proxy under an elevated shell would otherwise lose the stop it gets today. A matching wrapper without a creation time stays `unknown` even with nothing registered. +- **Snapshot type.** The snapshot type is `WindowsStopProcessEntry`, which is `WindowsProcessEntry` plus `startTime`, in the evidence module. `windowsProcessList` and the guarded inspection stay untouched until PR2 switches them. +- **The Schedule PID is read before admission.** Part A reads the Task Scheduler host PID from the SCM (cap 2 s) right after the registration probe, before admission, whenever a task is registered or a stray sweep runs. 030's tables do not list this read. Admission measures the time left on the caller's deadline, so the read's actual elapsed time is already taken out of what admission sees, the same way the step 1 stray enumeration is. +- **Not every effect is capped.** The registration probes, enumeration, Schedule PID, `/end`, WinSW stop and poll, terminates, manager state and `proxyAlive` run under caps. `openWatch` and the authorizer do not. `openWatch` is one `OpenProcess` per wrapper with no wait in it. The authorizer runs before each terminate, inside the shared 3 s terminate-and-wait window, so a slow authorizer eats that window: the next terminate gets less time, and an authorizer that never returns hangs Part A. PR2's authorizer (the updater's `mutationGate` check) has to be bounded by its caller. +- **Fractional timeouts.** A cap derived from the deadline (`windowEnd - now`) is fractional on the real clock. The spawned terminate fallback floors it to whole milliseconds before building the script and calling `execFile`, and spawns nothing when it floors to 0, because `execFile` reads a 0 timeout as "no timeout". + +## ARM64 decision for PR2 + +050 requires an ARM64 device run before the bounded fallback ships; without one, ARM64 ships the strict variant (report `unknown`, kill nothing). No ARM64 device run exists, so PR2 ships the strict variant. PR1 still contains the fallback script, and `terminateWindowsProcessBound` reaches it on its own: on win32 without the FFI primitive (ARM64, or an x64 whose DLL load failed) it runs the PowerShell script. PR2's terminate adapter therefore has to gate strict itself, for example by answering `unavailable` whenever `windowsProcessNative()` is null, and must not call `terminateWindowsProcessBound` there. Without a native API `openWindowsProcessWatch` already answers `unavailable`, so the wait reports `unknown` rather than proving an exit. + +## Notes for PR2 + +- **The route's caps.** 030 caps the `/api/stop` route's stray sweep at 4 s and its receipt-less Part A at 6 s. `stopWindowsServiceChecked` has no overall cap: its own caps add up to more (the stray path alone has a 5 s enumeration), and it never returns before its steps do. The route needs an outer race on its deadline that answers `service_state_unknown` on overrun, or cap overrides passed in, such as a 4 s enumeration for the sweep. + +## Open questions + +- Is the closing-sample accounting acceptable, or should the hold be measured differently, for example by shortening the final enumeration to 4.5 s to keep 030's totals? +- Should a WinSW-only stop also sweep a stray scheduler wrapper (that would add an enumeration and 3 s to the WinSW row)? +- `GetLastError` after a failed `OpenProcess` through Bun FFI separates "gone" from "denied". If the runtime clobbers it, the answer degrades to "unavailable" (`unknown`). The live x64 test in `tests/windows/windows-process-evidence.test.ts` checks this on the Windows CI lane. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e413e2845ac..c2ad2913d08 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1932,8 +1932,8 @@ "windows-deploy-close-regressions.test.ts": "windows", "windows-elevation-spawn.test.ts": "windows", "windows-elevation.test.ts": "windows", - "windows-guarded-manager.test.ts": "windows", - "windows-popup-fix.test.ts": "windows", + "windows-guarded-manager.test.ts": "windows", "windows-checked-stop.test.ts": "windows", "windows-stop-binding.test.ts": "windows", + "windows-popup-fix.test.ts": "windows", "windows-stop-confirm.test.ts": "windows", "windows-process-evidence.test.ts": "windows", "windows-process-priority.test.ts": "windows", "windows-scheduler-errors.test.ts": "windows", "windows-scheduler-install-verification.test.ts": "windows", "windows-lifecycle-root-acl.test.ts": "windows", diff --git a/src/service/windows-checked-stop.ts b/src/service/windows-checked-stop.ts new file mode 100644 index 00000000000..f81eb759eec --- /dev/null +++ b/src/service/windows-checked-stop.ts @@ -0,0 +1,432 @@ +/** + * Part A of the checked Windows service stop (#7771, 030 "Steps"): stop the manager, end + * the bound wrappers, then prove the manager inactive and every bound wrapper exited. + * + * Part A covers the manager and the wrappers only. The proxy child is stopped afterwards + * by the caller's graceful path (it may refuse, and a refusal forbids a forced stop), and + * the port is confirmed by Part B (src/service/windows-stop-confirm.ts). + * + * 1. Probe registration (task, WinSW; 2 s caps). An unreadable answer is unknown with + * nothing changed. With nothing registered, one enumeration (5 s) looks for a stray + * wrapper of a deleted task; unreadable, or nothing found, is absent. + * With a task registered or a stray sweep running, the Schedule host PID is read (2 s). + * Admission runs here, after the probe and before anything changes. + * 2. Bind (src/service/windows-stop-binding.ts) and open an exit watch per wrapper. + * 3. Ask the manager: schtasks /end (2 s); WinSW a non-waiting stop (2 s) and an SCM poll + * capped at + 2 s. Denied is denied; an overrun is unknown; neither goes on. + * 4. Terminate the bound survivors through the checked primitive, deepest first. + * 5. Wait on the bind-time watches (steps 4 and 5 share 3 s), then read the manager state + * once (1 s). No enumeration and no PowerShell run in the wait. + * + * Every external effect is injected; nothing here touches the machine by itself, and + * nothing calls it yet. + */ +import type { WinswStatus } from "../lib/winsw"; +import type { WindowsProcessWatch, WindowsTerminateResult } from "./windows-process-native"; +import type { WindowsStopProcessEntry } from "./windows-process-evidence"; +import type { WindowsSchedulerTaskProbe } from "./windows-scheduler"; +import { bindWindowsStopWrappers, type WindowsBoundWrapper, type WindowsStopBinding } from "./windows-stop-binding"; +import { + DEFAULT_WINSW_STOP_TIMEOUT_MS, + WINDOWS_STOP_CAPS, + windowsStopWorstCase, + type WindowsStopBudgetInput, +} from "./windows-stop-budget"; + +type MaybePromise = T | Promise; + +/** Monotonic clock and cancellable sleep; tests inject a virtual one. */ +export interface WindowsStopClock { + now(): number; + sleep(ms: number, signal?: AbortSignal): Promise; +} + +export const windowsStopClock: WindowsStopClock = { + now: () => performance.now(), + sleep: (ms, signal) => new Promise(resolve => { + if (signal?.aborted) { + resolve(); + return; + } + const done = (): void => { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, Math.max(0, ms)); + signal?.addEventListener("abort", done, { once: true }); + }), +}; + +export type WindowsStopReason = "access-denied" | "evidence-unreadable" | "unbound-wrapper" | "unverified-budget"; +export type WindowsCheckedStopOutcome = "absent" | "stopped" | "survived" | "denied" | "unknown" | "unverified-budget"; +export type WindowsTaskEndResult = "ended" | "not-running" | "denied" | "failed"; +export type WindowsWinswStopResult = "sent" | "denied" | "failed"; +export type WindowsWinswScmState = "stopped" | "running" | "pending" | "nonexistent" | "unknown"; + +export interface WindowsCheckedStopDeps { + clock?: WindowsStopClock; + /** This home's canonical launcher VBS and service script paths. */ + canonicalPaths: readonly string[]; + /** The identity-checked proxy child (readPid), when known. */ + childPid: number | null; + selfPid?: number; + probeScheduler(timeoutMs: number): MaybePromise; + probeWinsw(timeoutMs: number): MaybePromise; + enumerate(timeoutMs: number): MaybePromise; + scheduleServicePid(timeoutMs: number): MaybePromise; + endTask(timeoutMs: number): MaybePromise; + /** Running state of the task (schtasks /query); the final manager-state read. */ + taskState(timeoutMs: number): MaybePromise<"running" | "not-running" | "unknown">; + winswStop(timeoutMs: number): MaybePromise; + winswState(timeoutMs: number): MaybePromise; + /** The WinSW definition's ; 20 s for the generated one. */ + winswStopTimeoutMs?: number; + /** After WinSW reports stopped: is the proxy still alive? null = cannot tell. */ + proxyAlive(timeoutMs: number): MaybePromise; + openWatch(pid: number, startTime: number): MaybePromise; + terminate(pid: number, startTime: number, timeoutMs: number): MaybePromise; +} + +export interface WindowsStopAdmission { + /** Absolute deadline on the deps clock. */ + deadlineAt: number; + budget: Omit; +} + +export interface WindowsCheckedStopOptions { + /** A snapshot that already ran (guarded: revalidateManager's), reused for the bind. */ + bindSnapshot?: readonly WindowsStopProcessEntry[]; + admission?: WindowsStopAdmission; + /** Throws to refuse. Runs at entry and before each terminate. */ + authorize?: () => MaybePromise; +} + +export interface WindowsCheckedStopResult { + outcome: WindowsCheckedStopOutcome; + reason: WindowsStopReason | null; + /** Fixed text for logs and output; it never carries a path or a command line. */ + detail: string; + registration: { scheduler: "present" | "absent" | "unknown"; winsw: WinswStatus }; + /** The bound set (pid + creation time) for the later receipt record. */ + bound: WindowsBoundWrapper[]; + unbound: number[]; + terminated: number[]; + /** True once a manager stop or a terminate was attempted. */ + changed: boolean; + budget?: { requiredMs: number; remainingMs: number }; +} + +/** The authorizer refused; partial says what had already happened. */ +export class WindowsCheckedStopRefusedError extends Error { + constructor(readonly partial: WindowsCheckedStopResult, options: { cause: unknown }) { + super("The checked Windows service stop was refused by its authorizer.", options); + this.name = "WindowsCheckedStopRefusedError"; + } +} + +const C = WINDOWS_STOP_CAPS; +const WINSW_POLL_INTERVAL_MS = 250; +const WATCH_POLL_INTERVAL_MS = 100; +const TIMED_OUT = Symbol("timed-out"); + +type Capped = { ok: true; value: T } | { ok: false; timedOut: boolean }; + +/** + * Run one step under a cap. The race ends the wait for an asynchronous dependency; the + * elapsed check catches one that blocked past the cap anyway. Either is an overrun. + */ +export async function runCapped( + clock: WindowsStopClock, + capMs: number, + run: (timeoutMs: number) => MaybePromise, +): Promise> { + if (capMs <= 0) return { ok: false, timedOut: true }; + const started = clock.now(); + const abort = new AbortController(); + const work = Promise.resolve().then(() => run(capMs)); + work.catch(() => undefined); + try { + const value = await Promise.race([work, clock.sleep(capMs, abort.signal).then(() => TIMED_OUT)]); + if (value === TIMED_OUT || clock.now() - started > capMs) return { ok: false, timedOut: true }; + return { ok: true, value: value as T }; + } catch { + return { ok: false, timedOut: false }; + } finally { + abort.abort(); + } +} + +export async function stopWindowsServiceChecked( + deps: WindowsCheckedStopDeps, + options: WindowsCheckedStopOptions = {}, +): Promise { + const clock = deps.clock ?? windowsStopClock; + const watches = new Map>(); + const result: WindowsCheckedStopResult = { + outcome: "unknown", reason: null, detail: "", + registration: { scheduler: "unknown", winsw: "unknown" }, + bound: [], unbound: [], terminated: [], changed: false, + }; + const finish = (outcome: WindowsCheckedStopOutcome, reason: WindowsStopReason | null, detail: string) => { + result.outcome = outcome; + result.reason = reason; + result.detail = detail; + return result; + }; + const authorize = async (): Promise => { + if (!options.authorize) return; + try { + await options.authorize(); + } catch (cause) { + throw new WindowsCheckedStopRefusedError({ ...result, detail: "the authorizer refused the stop" }, { cause }); + } + }; + try { + await authorize(); + + // Step 1: registration. + const [scheduler, winsw] = await Promise.all([ + runCapped(clock, C.registrationProbe, deps.probeScheduler), + runCapped(clock, C.registrationProbe, deps.probeWinsw), + ]); + result.registration = { + scheduler: scheduler.ok && scheduler.value.status !== "unknown" ? scheduler.value.status : "unknown", + winsw: winsw.ok ? winsw.value : "unknown", + }; + if (result.registration.scheduler === "unknown" || result.registration.winsw === "unknown") { + return finish("unknown", "evidence-unreadable", "the service registration could not be read"); + } + const taskRegistered = result.registration.scheduler === "present"; + const winswRegistered = result.registration.winsw !== "nonexistent"; + const winswStarted = result.registration.winsw === "started"; + const readSchedulePid = async (): Promise => { + const pid = await runCapped(clock, C.scheduleServicePid, deps.scheduleServicePid); + return pid.ok ? pid.value : null; + }; + const bindContext = { + canonicalPaths: deps.canonicalPaths, + childPid: deps.childPid, + selfPid: deps.selfPid ?? process.pid, + }; + + let binding: WindowsStopBinding | null = null; + let schedulePid: number | null = null; + const stray = !taskRegistered && !winswRegistered; + if (stray) { + const snapshot = await runCapped(clock, C.enumeration, deps.enumerate); + // Nothing registered and nothing readable: today's best-effort sweep skips too. + if (!snapshot.ok || snapshot.value === null) { + return finish("absent", null, "no service is registered; the process list could not be read"); + } + schedulePid = await readSchedulePid(); + binding = bindWindowsStopWrappers({ + ...bindContext, processes: snapshot.value, taskRegistered: false, managerRegistered: false, + scheduleServicePid: schedulePid, + }); + if (binding.bound.length === 0 && binding.unbound.length === 0 && binding.unreadable.length === 0) { + return finish("absent", null, "no service is registered and no wrapper of this home is running"); + } + // Neither of these changes anything, so neither waits for admission. + result.unbound = binding.unbound.map(entry => entry.pid); + if (binding.unreadable.length > 0) { + return finish("unknown", "evidence-unreadable", "a possible wrapper of this home has unreadable evidence"); + } + if (binding.bound.length === 0) { + return await unboundOnlyVerdict(binding, deps, watches) + ? finish("survived", "unbound-wrapper", "a wrapper of this home is running but cannot be tied to a manager") + : finish("absent", null, "no service is registered and no wrapper of this home is running"); + } + } else if (taskRegistered) { + schedulePid = await readSchedulePid(); + } + + // Admission: everything still to come must fit before anything changes. + if (options.admission) { + const budget = windowsStopWorstCase({ + ...options.admission.budget, + managers: { scheduler: taskRegistered, winsw: winswStarted, stray }, + enumerationReused: stray || options.bindSnapshot !== undefined, + winswStopTimeoutMs: options.admission.budget.winswStopTimeoutMs ?? deps.winswStopTimeoutMs, + }); + const remainingMs = options.admission.deadlineAt - clock.now(); + result.budget = { requiredMs: budget.totalMs, remainingMs }; + if (remainingMs < budget.totalMs) { + return finish("unverified-budget", "unverified-budget", "the remaining time cannot hold the worst case of the stop"); + } + } + + // Step 2: bind. + if (taskRegistered) { + let processes = options.bindSnapshot ?? null; + if (processes === null) { + const snapshot = await runCapped(clock, C.enumeration, deps.enumerate); + if (!snapshot.ok || snapshot.value === null) { + return finish("unknown", "evidence-unreadable", "the process list could not be read"); + } + processes = snapshot.value; + } + binding = bindWindowsStopWrappers({ + ...bindContext, processes, taskRegistered: true, managerRegistered: true, + scheduleServicePid: schedulePid, + }); + } + if (binding) { + result.unbound = binding.unbound.map(entry => entry.pid); + if (binding.unreadable.length > 0) { + return finish("unknown", "evidence-unreadable", "a possible wrapper of this home has unreadable evidence"); + } + } + const targets: WindowsBoundWrapper[] = []; + for (const wrapper of binding?.bound ?? []) { + const watch = await deps.openWatch(wrapper.pid, wrapper.startTime); + if (watch.status === "gone" || watch.status === "reused") continue; + if (watch.status === "denied") { + return finish("denied", "access-denied", "a bound wrapper cannot be opened by this account"); + } + if (watch.status === "open") watches.set(wrapper.pid, watch); + targets.push(wrapper); + } + result.bound = targets; + const unboundWatches = new Map(); + for (const entry of binding?.unbound ?? []) { + if (entry.startTime === null) continue; + const watch = await deps.openWatch(entry.pid, entry.startTime); + unboundWatches.set(entry.pid, watch); + if (watch.status === "open") watches.set(entry.pid, watch); + } + + // Step 3: ask the manager. + if (taskRegistered) { + result.changed = true; + const ended = await runCapped(clock, C.taskEnd, deps.endTask); + if (!ended.ok) return finish("unknown", "evidence-unreadable", "the task end command did not answer within its cap"); + if (ended.value === "denied") return finish("denied", "access-denied", "ending the task was denied"); + if (ended.value === "failed") return finish("unknown", "evidence-unreadable", "the task end command failed"); + } + if (winswStarted) { + result.changed = true; + const stop = await runCapped(clock, C.winswStopCommand, deps.winswStop); + if (!stop.ok) return finish("unknown", "evidence-unreadable", "the native service stop did not answer within its cap"); + if (stop.value === "denied") return finish("denied", "access-denied", "stopping the native service was denied"); + if (stop.value === "failed") return finish("unknown", "evidence-unreadable", "the native service stop failed"); + const pollEnd = clock.now() + (deps.winswStopTimeoutMs ?? DEFAULT_WINSW_STOP_TIMEOUT_MS) + C.winswPollMargin; + for (;;) { + const left = pollEnd - clock.now(); + const state = await runCapped(clock, Math.min(C.managerState, left), deps.winswState); + if (state.ok && (state.value === "stopped" || state.value === "nonexistent")) break; + if (pollEnd - clock.now() <= 0) { + return finish("unknown", "evidence-unreadable", "the native service did not report stopped within its stop timeout"); + } + await clock.sleep(Math.min(WINSW_POLL_INTERVAL_MS, pollEnd - clock.now())); + } + } + + // Steps 4 and 5: terminate the bound survivors, then wait on the bind-time watches. + const windowEnd = clock.now() + C.terminateAndWait; + const exited = new Map(); + for (const wrapper of targets) { + if (watches.get(wrapper.pid)?.exited() === true) { + exited.set(wrapper.pid, true); + continue; + } + await authorize(); + result.changed = true; + const outcome = await runCapped(clock, windowEnd - clock.now(), + timeoutMs => deps.terminate(wrapper.pid, wrapper.startTime, timeoutMs)); + if (!outcome.ok) return finish("unknown", "evidence-unreadable", "terminating a bound wrapper did not answer within its cap"); + const status = outcome.value.status; + if (status === "denied") return finish("denied", "access-denied", "terminating a bound wrapper was denied"); + if (status === "unavailable") return finish("unknown", "evidence-unreadable", "a bound wrapper could not be terminated"); + if (status === "terminated") { + result.terminated.push(wrapper.pid); + if (outcome.value.exited === true) exited.set(wrapper.pid, true); + } else { + // gone, or reused: the bound process no longer exists either way, and a reused PID + // is a different process that is left untouched. + exited.set(wrapper.pid, true); + } + } + const pending = (): WindowsBoundWrapper[] => targets.filter(wrapper => { + if (exited.get(wrapper.pid)) return false; + if (watches.get(wrapper.pid)?.exited() === true) { + exited.set(wrapper.pid, true); + return false; + } + return true; + }); + while (pending().length > 0 && clock.now() < windowEnd) { + await clock.sleep(Math.min(WATCH_POLL_INTERVAL_MS, windowEnd - clock.now())); + } + let boundAlive = false; + let unreadable = false; + for (const wrapper of pending()) { + const state = watches.get(wrapper.pid)?.exited(); + if (state === false) boundAlive = true; + else unreadable = true; + } + + // Step 5: the manager state, read once. + let managerActive = false; + if (taskRegistered) { + const state = await runCapped(clock, C.managerState, deps.taskState); + if (state.ok && state.value === "running") managerActive = true; + else if (!state.ok || state.value === "unknown") unreadable = true; + } + if (winswStarted) { + const stepEnd = clock.now() + C.managerState; + const state = await runCapped(clock, C.managerState, deps.winswState); + if (state.ok && (state.value === "running" || state.value === "pending")) managerActive = true; + else if (!state.ok || state.value === "unknown") unreadable = true; + const alive = await runCapped(clock, stepEnd - clock.now(), deps.proxyAlive); + if (alive.ok && alive.value === true) managerActive = true; + } + + const unboundAlive = binding ? unboundStillAlive(binding, unboundWatches) : false; + if (unboundAlive) { + return finish("survived", "unbound-wrapper", "a wrapper of this home is running but cannot be tied to a manager"); + } + if (boundAlive || managerActive) return finish("survived", null, "the manager or a bound wrapper is still running"); + if (unreadable) return finish("unknown", "evidence-unreadable", "the stop could not be verified"); + return finish("stopped", null, "the manager is stopped and every bound wrapper has exited"); + } finally { + for (const watch of watches.values()) { + try { watch.close(); } catch { /* closing a handle cannot change the verdict */ } + } + } +} + +/** An unbound wrapper counts as alive unless its watch proves it exited. */ +function unboundStillAlive(binding: WindowsStopBinding, watches: ReadonlyMap): boolean { + return binding.unbound.some(entry => { + const watch = watches.get(entry.pid); + if (!watch) return true; + if (watch.status === "gone" || watch.status === "reused") return false; + return watch.status !== "open" || watch.exited() !== true; + }); +} + +/** Stray path with nothing bound: are the unbound wrappers still there? Read-only. */ +async function unboundOnlyVerdict( + binding: WindowsStopBinding, + deps: WindowsCheckedStopDeps, + opened: Map>, +): Promise { + const watches = new Map(); + for (const entry of binding.unbound) { + if (entry.startTime === null) continue; + const watch = await deps.openWatch(entry.pid, entry.startTime); + watches.set(entry.pid, watch); + if (watch.status === "open") opened.set(entry.pid, watch); + } + return unboundStillAlive(binding, watches); +} + +/** The updater's variant: the authorizer runs at entry and before every terminate. */ +export function guardedStopWindowsServiceChecked( + authorize: () => MaybePromise, + deps: WindowsCheckedStopDeps, + options: Omit = {}, +): Promise { + return stopWindowsServiceChecked(deps, { ...options, authorize }); +} diff --git a/src/service/windows-process-evidence.ts b/src/service/windows-process-evidence.ts new file mode 100644 index 00000000000..e5c51ec3df1 --- /dev/null +++ b/src/service/windows-process-evidence.ts @@ -0,0 +1,292 @@ +/** + * Process evidence for the checked Windows service stop (#7771): a process snapshot that + * carries creation times, the Task Scheduler service PID, a checked terminate, and a + * bind-time exit watch. Each facade uses the in-process primitive from + * src/service/windows-process-native.ts where Bun's FFI runs (Windows x64) and a bounded + * spawned fallback elsewhere on Windows (ARM64): + * + * - snapshot: one Get-CimInstance Win32_Process query with CreationDate. On x64 every + * creation time is re-read through GetProcessTimes and kept only when it agrees with + * CIM to the millisecond, so the bound value is the exact one the handle terminate + * compares later, and a PID reused between the CIM query and the native read is null + * (unreadable) instead of silently taking the new process's identity; + * - Schedule PID: QueryServiceStatusEx on x64, else sc.exe queryex Schedule run from the + * trusted system directory with a cap; unreadable is null; + * - terminate: the handle primitive on x64. ARM64 runs ONE trusted PowerShell script that + * re-reads Get-Process -Id StartTime, compares it with the bound CIM creation time + * (1 s tolerance, UTC), calls Kill() on the same Process object and reads the exit back. + * Kill() opens its own handle by PID, so a window between the in-script check and the + * kill remains; that, and the 1 s tolerance, are the accepted residuals of the fallback; + * - watch: x64 only; elsewhere "unavailable", and the checked stop takes the exit from + * the terminate read-back. + * + * Only integers ever reach a script; paths and command lines are compared in TypeScript. + * Nothing calls this module yet. + */ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { resolveTrustedWindowsPowerShellExe, resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; +import type { WindowsProcessEntry } from "./windows-guarded-manager"; +import { + windowsProcessNative, + windowsProcessPlatform, + windowsProcessPlatformSimulated, + type WindowsProcessWatch, + type WindowsTerminateResult, +} from "./windows-process-native"; + +/** A spawned helper. "powershell" and "sc" resolve inside the trusted system directory. */ +export interface WindowsEvidenceCommand { + program: "powershell" | "sc"; + args: string[]; + timeoutMs: number; + maxBufferBytes: number; +} + +/** Runs a command and resolves stdout; rejects on a nonzero exit, a timeout or overflow. */ +export type WindowsEvidenceRunner = (command: WindowsEvidenceCommand) => Promise; + +export interface WindowsEvidenceOptions { + timeoutMs: number; + /** Injected in tests. Unset on a simulated platform means no spawn at all. */ + run?: WindowsEvidenceRunner; +} + +/** A process-table row with the creation time the binding rule requires (null = unreadable). */ +export type WindowsStopProcessEntry = WindowsProcessEntry & { startTime: number | null }; + +const SNAPSHOT_MAX_BUFFER = 16 * 1024 * 1024; +const SMALL_MAX_BUFFER = 64 * 1024; +const NATIVE_CIM_TOLERANCE_MS = 1; +/** Kill() read-back inside the ARM64 script; the checked stop's wait covers the rest. */ +export const FALLBACK_TERMINATE_EXIT_WAIT_MS = 1_000; + +function trustedProgram(program: WindowsEvidenceCommand["program"]): string { + if (program === "powershell") return resolveTrustedWindowsPowerShellExe(); + const sc = join(resolveTrustedWindowsSystemDirectory(), "sc.exe"); + if (!existsSync(sc)) throw new Error("sc.exe was not found in the trusted system directory."); + return sc; +} + +const defaultRunner: WindowsEvidenceRunner = command => new Promise((resolve, reject) => { + let file: string; + try { + file = trustedProgram(command.program); + } catch (error) { + reject(error); + return; + } + execFile(file, command.args, { + encoding: "utf8", + timeout: command.timeoutMs, + maxBuffer: command.maxBufferBytes, + windowsHide: true, + }, (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + }); +}); + +function runnerFor(options: WindowsEvidenceOptions): WindowsEvidenceRunner | null { + if (options.run) return options.run; + if (windowsProcessPlatformSimulated() || windowsProcessPlatform() !== "win32") return null; + return defaultRunner; +} + +function powershellArgs(script: string): string[] { + return ["-NoProfile", "-NoLogo", "-NonInteractive", "-Command", script]; +} + +/** The snapshot query. CreationDate is emitted as an ISO UTC string, never as a JSON date. */ +export const WINDOWS_PROCESS_SNAPSHOT_SCRIPT = "Get-CimInstance Win32_Process | Select-Object " + + "ProcessId,ParentProcessId,Name,CommandLine," + + "@{Name='CreationDate';Expression={ if ($_.CreationDate) { $_.CreationDate.ToUniversalTime().ToString('o') } else { $null } }} " + + "| ConvertTo-Json -Compress"; + +/** Parse the snapshot JSON. Unparseable output is null: an unread table proves nothing. */ +export function parseWindowsProcessSnapshot(output: string): WindowsStopProcessEntry[] | null { + const trimmed = output.trim(); + if (!trimmed) return []; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const entries: WindowsStopProcessEntry[] = []; + for (const row of rows) { + if (typeof row !== "object" || row === null) continue; + const record = row as Record; + const pid = Number(record.ProcessId); + const parent = Number(record.ParentProcessId); + if (!Number.isSafeInteger(pid) || pid <= 0) continue; + const created = typeof record.CreationDate === "string" ? Date.parse(record.CreationDate) : Number.NaN; + entries.push({ + pid, + parentPid: Number.isSafeInteger(parent) && parent > 0 ? parent : null, + name: typeof record.Name === "string" ? record.Name : null, + commandLine: typeof record.CommandLine === "string" ? record.CommandLine : null, + startTime: Number.isFinite(created) ? created : null, + }); + } + return entries; +} + +/** + * Replace each CIM creation time with the native one when both agree to the millisecond. + * A disagreement (or a missing side) is null: the binding rule then treats the process as + * unreadable rather than binding an identity the terminate check would not reproduce. + */ +export function overlayNativeCreationTimes( + entries: readonly WindowsStopProcessEntry[], + creationTime: (pid: number) => number | null, +): WindowsStopProcessEntry[] { + return entries.map(entry => { + if (entry.startTime === null) return entry; + let native: number | null; + try { + native = creationTime(entry.pid); + } catch { + native = null; + } + const agrees = native !== null && Math.abs(native - entry.startTime) <= NATIVE_CIM_TOLERANCE_MS; + return { ...entry, startTime: agrees ? native : null }; + }); +} + +/** Every process with its creation time, or null when the table cannot be read. */ +export async function windowsProcessSnapshot(options: WindowsEvidenceOptions): Promise { + const run = runnerFor(options); + if (!run) return null; + let entries: WindowsStopProcessEntry[] | null; + try { + entries = parseWindowsProcessSnapshot(await run({ + program: "powershell", + args: powershellArgs(WINDOWS_PROCESS_SNAPSHOT_SCRIPT), + timeoutMs: options.timeoutMs, + maxBufferBytes: SNAPSHOT_MAX_BUFFER, + })); + } catch { + return null; + } + if (entries === null) return null; + const native = windowsProcessNative(); + return native ? overlayNativeCreationTimes(entries, pid => native.creationTime(pid)) : entries; +} + +/** + * sc.exe queryex Schedule: the PID only while STATE is 4 (RUNNING). The field names and + * the numeric state are not localized; the trailing state word is ignored. + */ +export function parseScQueryexPid(output: string): number | null { + const state = /^\s*STATE\s*:\s*(\d+)/m.exec(output); + if (!state || Number(state[1]) !== 4) return null; + const pid = /^\s*PID\s*:\s*(\d+)\s*$/m.exec(output); + if (!pid) return null; + const value = Number(pid[1]); + return Number.isSafeInteger(value) && value > 0 ? value : null; +} + +/** The Task Scheduler service host's PID, or null when it cannot be proven. */ +export async function readScheduleServicePid(options: WindowsEvidenceOptions): Promise { + const native = options.run ? null : windowsProcessNative(); + if (native) { + try { + return native.scheduleServicePid(); + } catch { + return null; + } + } + const run = runnerFor(options); + if (!run) return null; + try { + return parseScQueryexPid(await run({ + program: "sc", + args: ["queryex", "Schedule"], + timeoutMs: options.timeoutMs, + maxBufferBytes: SMALL_MAX_BUFFER, + })); + } catch { + return null; + } +} + +/** + * The ARM64 terminate script. pid and the bound time must be safe integers; nothing else + * is interpolated. One status word is printed on the last line. + */ +export function windowsFallbackTerminateScript(pid: number, boundStartTime: number, exitWaitMs: number): string { + for (const value of [pid, boundStartTime, exitWaitMs]) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("terminate script inputs must be non-negative integers"); + } + return [ + "$ErrorActionPreference = 'Stop';", + "try { $p = Get-Process -Id " + pid + " -ErrorAction Stop } catch { 'gone'; exit 0 };", + "try { $start = $p.StartTime.ToUniversalTime() } catch {", + " try { if ($p.HasExited) { 'gone' } else { 'denied' } } catch { 'denied' }; exit 0 };", + "$bound = [DateTimeOffset]::FromUnixTimeMilliseconds(" + boundStartTime + ").UtcDateTime;", + "if ([Math]::Abs(($start - $bound).TotalMilliseconds) -gt 1000) { 'reused'; exit 0 };", + "try { $p.Kill() }", + "catch [System.InvalidOperationException] { 'gone'; exit 0 }", + "catch [System.ComponentModel.Win32Exception] { 'denied'; exit 0 }", + "catch { 'unavailable'; exit 0 };", + "if ($p.WaitForExit(" + exitWaitMs + ")) { 'terminated-exited' } else { 'terminated' }", + ].join(" "); +} + +/** Map the script's last line; anything unexpected is "unavailable", never success. */ +export function parseFallbackTerminateOutput(output: string): WindowsTerminateResult { + const lines = output.split(/\r?\n/).map(line => line.trim()).filter(Boolean); + const word = lines[lines.length - 1] ?? ""; + if (word === "terminated-exited") return { status: "terminated", exited: true }; + if (word === "terminated") return { status: "terminated", exited: false }; + if (word === "gone" || word === "reused" || word === "denied") return { status: word }; + return { status: "unavailable" }; +} + +/** Terminate pid only if it is still the process created at boundStartTime. */ +export async function terminateWindowsProcessBound( + pid: number, + boundStartTime: number, + options: WindowsEvidenceOptions, +): Promise { + const native = options.run ? null : windowsProcessNative(); + if (native) { + try { + return native.terminate(pid, boundStartTime); + } catch { + return { status: "unavailable" }; + } + } + const run = runnerFor(options); + if (!run) return { status: "unavailable" }; + // A timeout derived from a deadline can be fractional, and both execFile and the script + // take whole milliseconds. A timeout that floors to 0 would mean no timeout at all. + const timeoutMs = Math.floor(options.timeoutMs); + if (!(timeoutMs > 0)) return { status: "unavailable" }; + const exitWaitMs = Math.max(0, Math.min(FALLBACK_TERMINATE_EXIT_WAIT_MS, timeoutMs - 500)); + try { + return parseFallbackTerminateOutput(await run({ + program: "powershell", + args: powershellArgs(windowsFallbackTerminateScript(pid, boundStartTime, exitWaitMs)), + timeoutMs, + maxBufferBytes: SMALL_MAX_BUFFER, + })); + } catch { + return { status: "unavailable" }; + } +} + +/** A bind-time exit watch; "unavailable" wherever the handle primitive cannot run. */ +export function openWindowsProcessWatch(pid: number, boundStartTime: number): WindowsProcessWatch { + const native = windowsProcessNative(); + if (!native) return { status: "unavailable" }; + try { + return native.openWatch(pid, boundStartTime); + } catch { + return { status: "unavailable" }; + } +} diff --git a/src/service/windows-process-native.ts b/src/service/windows-process-native.ts new file mode 100644 index 00000000000..0c5b1bff635 --- /dev/null +++ b/src/service/windows-process-native.ts @@ -0,0 +1,262 @@ +/** + * In-process Windows process primitives for the checked service stop (#7771). + * + * Four questions the checked stop asks of the kernel and the SCM: + * + * - when was this process created (GetProcessTimes), so a PID that was reused between + * bind and terminate is told apart from the process that was bound; + * - terminate exactly the bound process: OpenProcess, read the creation time on THAT + * handle, compare, TerminateProcess on the same handle, close. There is no gap between + * the identity check and the kill in which the PID could be reused; + * - has a bound process exited (WaitForSingleObject with a zero timeout on a handle + * opened at bind time), so the wait needs neither an enumeration nor PowerShell; + * - which process hosts the Task Scheduler service (QueryServiceStatusEx on "Schedule"), + * the parent the binding rule accepts for a launcher the scheduler started. + * + * Loading follows src/config/windows-process-command-line.ts: bun:ffi is required lazily + * (every importer loads this module on every platform), the DLLs load from the + * GetSystemDirectoryW path so the search order cannot substitute them, a failed load is + * cached as null, and every handle is closed in a finally. Windows ARM64 and every other + * platform get null here; src/service/windows-process-evidence.ts then uses the spawned + * fallbacks. A test that simulates a platform never reaches the real kernel. + * + * Nothing calls this module yet; the stop paths move to it in a later change. + */ +import { join } from "node:path"; + +import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation"; + +const PROCESS_TERMINATE = 0x0001; +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; +const SYNCHRONIZE = 0x0010_0000; +const WAIT_OBJECT_0 = 0; +const WAIT_TIMEOUT = 0x102; +const ERROR_ACCESS_DENIED = 5; +const ERROR_INVALID_PARAMETER = 87; +const SC_MANAGER_CONNECT = 0x0001; +const SERVICE_QUERY_STATUS = 0x0004; +const SC_STATUS_PROCESS_INFO = 0; +const SERVICE_RUNNING = 4; +/** SERVICE_STATUS_PROCESS is nine DWORDs; dwCurrentState is the second, dwProcessId the eighth. */ +const SERVICE_STATUS_PROCESS_BYTES = 36; +const SERVICE_STATUS_STATE_OFFSET = 4; +const SERVICE_STATUS_PID_OFFSET = 28; +/** 100 ns intervals between 1601-01-01 and 1970-01-01. */ +const FILETIME_UNIX_EPOCH = 116_444_736_000_000_000n; +const TERMINATE_EXIT_CODE = 1; + +/** Result of one checked terminate. Only "terminated" means this call ended the process. */ +export type WindowsTerminateStatus = "terminated" | "gone" | "reused" | "denied" | "unavailable"; + +export interface WindowsTerminateResult { + status: WindowsTerminateStatus; + /** + * True when the primitive itself observed the process exit after terminating it. The + * x64 primitive does not wait (the checked stop waits on its bind-time handles); the + * ARM64 fallback reads the exit back inside its script and reports it here. + */ + exited?: boolean; +} + +/** A bind-time handle the checked stop polls instead of enumerating processes again. */ +export type WindowsProcessWatch = + | { status: "open"; exited(): boolean | null; close(): void } + | { status: "gone" | "reused" | "denied" | "unavailable" }; + +export interface WindowsProcessNativeApi { + /** Creation time in epoch ms (FILETIME truncated to ms), or null when unreadable. */ + creationTime(pid: number): number | null; + terminate(pid: number, boundStartTime: number): WindowsTerminateResult; + openWatch(pid: number, boundStartTime: number): WindowsProcessWatch; + /** PID of the running Task Scheduler service host, or null when it cannot be read. */ + scheduleServicePid(): number | null; +} + +/** FILETIME (100 ns since 1601) to epoch ms, truncated. Zero or pre-1970 is unreadable. */ +export function fileTimeToEpochMs(fileTime: bigint): number | null { + if (fileTime <= FILETIME_UNIX_EPOCH) return null; + const ms = (fileTime - FILETIME_UNIX_EPOCH) / 10_000n; + return ms > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(ms); +} + +/** Decode SERVICE_STATUS_PROCESS: the PID only while the service reports RUNNING. */ +export function decodeServiceStatusProcess(buffer: Uint8Array): number | null { + if (buffer.byteLength < SERVICE_STATUS_PROCESS_BYTES) return null; + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + if (view.getUint32(SERVICE_STATUS_STATE_OFFSET, true) !== SERVICE_RUNNING) return null; + const pid = view.getUint32(SERVICE_STATUS_PID_OFFSET, true); + return pid > 0 ? pid : null; +} + +let nativeApi: WindowsProcessNativeApi | null | undefined; +let nativeApiForTests: WindowsProcessNativeApi | null | undefined; +let platformForTests: NodeJS.Platform | null = null; + +/** Replace the native API in tests; null forces "unavailable", undefined restores the real one. */ +export function setWindowsProcessNativeForTests(next: WindowsProcessNativeApi | null | undefined): void { + nativeApiForTests = next; +} + +/** + * Simulate a platform in tests. While set, the real kernel is never called: the native + * API reads as unavailable unless a test API is installed as well. + */ +export function setWindowsProcessPlatformForTests(next: NodeJS.Platform | null): void { + platformForTests = next; +} + +/** The platform the Windows stop primitives act for (the simulated one in tests). */ +export function windowsProcessPlatform(): NodeJS.Platform { + return platformForTests ?? process.platform; +} + +/** True while a test simulates the platform; nothing may then reach the real machine. */ +export function windowsProcessPlatformSimulated(): boolean { + return platformForTests !== null; +} + +type Handle = number | bigint; + +function loadNativeApi(): WindowsProcessNativeApi | null { + if (nativeApi !== undefined) return nativeApi; + nativeApi = null; + if (process.platform !== "win32" || process.arch !== "x64") return null; + let ptr: typeof import("bun:ffi").ptr; + let kernel32; + let advapi32; + try { + const ffi = require("bun:ffi") as typeof import("bun:ffi"); + ptr = ffi.ptr; + const systemDirectory = resolveTrustedWindowsSystemDirectory(); + kernel32 = ffi.dlopen(join(systemDirectory, "kernel32.dll"), { + OpenProcess: { args: ["u32", "i32", "u32"], returns: "u64" }, + CloseHandle: { args: ["u64"], returns: "i32" }, + GetProcessTimes: { args: ["u64", "ptr", "ptr", "ptr", "ptr"], returns: "i32" }, + TerminateProcess: { args: ["u64", "u32"], returns: "i32" }, + WaitForSingleObject: { args: ["u64", "u32"], returns: "u32" }, + GetLastError: { args: [], returns: "u32" }, + }).symbols; + advapi32 = ffi.dlopen(join(systemDirectory, "advapi32.dll"), { + OpenSCManagerW: { args: ["ptr", "ptr", "u32"], returns: "u64" }, + OpenServiceW: { args: ["u64", "ptr", "u32"], returns: "u64" }, + QueryServiceStatusEx: { args: ["u64", "i32", "ptr", "u32", "ptr"], returns: "i32" }, + CloseServiceHandle: { args: ["u64"], returns: "i32" }, + }).symbols; + } catch { + return null; + } + const k = kernel32; + const a = advapi32; + + const handleCreationTime = (handle: Handle): number | null => { + const times = new BigUint64Array(4); + const base = ptr(times); + const ok = k.GetProcessTimes(handle, base, base + 8, base + 16, base + 24); + return ok ? fileTimeToEpochMs(times[0]!) : null; + }; + // true = exited, false = running, null = unreadable. + const handleExited = (handle: Handle): boolean | null => { + const wait = Number(k.WaitForSingleObject(handle, 0)) >>> 0; + if (wait === WAIT_OBJECT_0) return true; + if (wait === WAIT_TIMEOUT) return false; + return null; + }; + // GetLastError is read immediately after the failed call. If the runtime ever clobbers + // it, the answer degrades to "unavailable", which the checked stop reports as unknown. + const openFailure = (): "gone" | "denied" | "unavailable" => { + const error = Number(k.GetLastError()) >>> 0; + if (error === ERROR_INVALID_PARAMETER) return "gone"; + if (error === ERROR_ACCESS_DENIED) return "denied"; + return "unavailable"; + }; + + nativeApi = { + creationTime(pid) { + const handle = k.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if (!handle) return null; + try { + return handleCreationTime(handle); + } finally { + k.CloseHandle(handle); + } + }, + terminate(pid, boundStartTime) { + const handle = k.OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid); + if (!handle) return { status: openFailure() }; + try { + const created = handleCreationTime(handle); + if (created === null) return { status: "unavailable" }; + if (created !== boundStartTime) return { status: "reused" }; + const before = handleExited(handle); + if (before === true) return { status: "gone" }; + if (before === null) return { status: "unavailable" }; + if (k.TerminateProcess(handle, TERMINATE_EXIT_CODE)) return { status: "terminated" }; + const error = Number(k.GetLastError()) >>> 0; + // TerminateProcess on a process that exited meanwhile also fails with access denied. + if (handleExited(handle) === true) return { status: "gone" }; + return { status: error === ERROR_ACCESS_DENIED ? "denied" : "unavailable" }; + } finally { + k.CloseHandle(handle); + } + }, + openWatch(pid, boundStartTime) { + const handle = k.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if (!handle) return { status: openFailure() }; + let keep = false; + try { + const created = handleCreationTime(handle); + if (created === null) return { status: "unavailable" }; + if (created !== boundStartTime) return { status: "reused" }; + keep = true; + let closed = false; + return { + status: "open", + exited: () => (closed ? null : handleExited(handle)), + close: () => { + if (closed) return; + closed = true; + k.CloseHandle(handle); + }, + }; + } finally { + if (!keep) k.CloseHandle(handle); + } + }, + scheduleServicePid() { + const manager = a.OpenSCManagerW(null, null, SC_MANAGER_CONNECT); + if (!manager) return null; + try { + const name = Buffer.from("Schedule\0", "utf16le"); + const service = a.OpenServiceW(manager, ptr(name), SERVICE_QUERY_STATUS); + if (!service) return null; + try { + const buffer = new Uint8Array(SERVICE_STATUS_PROCESS_BYTES); + const needed = new Uint32Array(1); + const ok = a.QueryServiceStatusEx( + service, SC_STATUS_PROCESS_INFO, ptr(buffer), SERVICE_STATUS_PROCESS_BYTES, ptr(needed), + ); + return ok ? decodeServiceStatusProcess(buffer) : null; + } finally { + a.CloseServiceHandle(service); + } + } finally { + a.CloseServiceHandle(manager); + } + }, + }; + return nativeApi; +} + +/** + * The native API, or null where it cannot run: non-Windows, Windows ARM64, a failed DLL + * load, or a test that simulates a platform without installing a test API. + */ +export function windowsProcessNative(): WindowsProcessNativeApi | null { + if (nativeApiForTests !== undefined) return nativeApiForTests; + if (platformForTests !== null) return null; + try { + return loadNativeApi(); + } catch { + return null; + } +} diff --git a/src/service/windows-stop-binding.ts b/src/service/windows-stop-binding.ts new file mode 100644 index 00000000000..b1c45dd6280 --- /dev/null +++ b/src/service/windows-stop-binding.ts @@ -0,0 +1,187 @@ +/** + * Which processes the checked Windows stop may terminate (#7771, 030 "Binding rule"). + * + * A process is bound, and only a bound process is ever terminated, when ALL hold: + * + * 1. its image is wscript.exe, cscript.exe or cmd.exe. The proxy child is observed for + * lineage only and is never a target; + * 2. its command line carries this home's canonical launcher VBS or service script path + * as a complete token (commandLineHasPathToken); + * 3. lineage: it is an ancestor of the identity-checked child, or a descendant of a bound + * wrapper (which covers "its parent is a bound wrapper"), or its parent is the Task + * Scheduler service host proven by the SCM PID, or, only while this home's task is + * registered, its parent is gone. A parent PID that is not in the table, or whose + * creation time is later than the wrapper's (a reused PID), counts as gone; + * 4. a creation time was read for it. + * + * A wrapper that passes 1-2 but fails lineage (a launcher started by hand from a live + * shell, an orphan while no task is registered) is reported as unbound and never touched. + * A candidate whose evidence cannot be read is reported as unreadable, never skipped and + * never killed: a matching wrapper without a creation time or image name, a matching + * wrapper whose parent PID is unreadable while the task is registered, and a wrapper-image + * process with no readable command line that descends from a bound wrapper or, while a + * manager is registered, is an ancestor of the child. + * + * Pure: the caller supplies the snapshot and every fact; nothing here reads the machine. + */ +import { createHash } from "node:crypto"; + +import { commandLineHasPathToken } from "./windows-guarded-manager"; +import type { WindowsStopProcessEntry } from "./windows-process-evidence"; + +const WRAPPER_IMAGES = new Set(["wscript.exe", "cscript.exe", "cmd.exe"]); +const DEPTH_LIMIT = 32; + +export interface WindowsStopBindingContext { + processes: readonly WindowsStopProcessEntry[]; + /** This home's canonical launcher VBS and service script paths. */ + canonicalPaths: readonly string[]; + /** The identity-checked proxy child (readPid), when there is one. */ + childPid: number | null; + /** This home's OpenCodex Task Scheduler task is registered (enables "parent gone"). */ + taskRegistered: boolean; + /** Any manager (task or WinSW) is registered: unreadable ancestors of the child count. */ + managerRegistered: boolean; + /** PID of the Task Scheduler service host from the SCM; null disables that clause. */ + scheduleServicePid: number | null; + /** The stopping process itself, never a candidate. */ + selfPid?: number; +} + +/** What a later receipt records about one bound wrapper. The command line is only digested. */ +export interface WindowsBoundWrapper { + pid: number; + startTime: number; + image: string; + parentPid: number | null; + commandLineDigest: string; +} + +export interface WindowsStopBinding { + /** Terminate order: the deepest bound wrapper first (the looping cmd.exe before its launcher). */ + bound: WindowsBoundWrapper[]; + /** Match image and path but fail lineage. Never terminated; alive at the end = unbound-wrapper. */ + unbound: { pid: number; startTime: number | null }[]; + /** Candidates whose evidence cannot be read; any entry makes the stop unknown. */ + unreadable: number[]; + /** The child as observed in the snapshot, for lineage and a WinSW survival check. */ + child: { pid: number; startTime: number | null } | null; +} + +/** The lowercased image name when it is one of the three wrapper images, else null. */ +export function windowsWrapperImage(name: string | null | undefined): string | null { + if (!name) return null; + const base = name.trim().toLowerCase().split(/[\\/]/).pop() ?? ""; + return WRAPPER_IMAGES.has(base) ? base : null; +} + +function digest(commandLine: string): string { + return createHash("sha256").update(commandLine, "utf8").digest("hex"); +} + +type ParentLink = WindowsStopProcessEntry | "gone" | "unreadable"; + +export function bindWindowsStopWrappers(context: WindowsStopBindingContext): WindowsStopBinding { + const processes = context.processes.filter(entry => entry.pid !== context.selfPid); + const byPid = new Map(processes.map(entry => [entry.pid, entry])); + + const parentLink = (entry: WindowsStopProcessEntry): ParentLink => { + if (entry.parentPid === null) return "unreadable"; + const parent = byPid.get(entry.parentPid); + if (!parent) return "gone"; + if (entry.startTime !== null && parent.startTime !== null && parent.startTime > entry.startTime) return "gone"; + return parent; + }; + const ancestors = (entry: WindowsStopProcessEntry): WindowsStopProcessEntry[] => { + const chain: WindowsStopProcessEntry[] = []; + const seen = new Set([entry.pid]); + let current = entry; + for (let depth = 0; depth < DEPTH_LIMIT; depth += 1) { + const link = parentLink(current); + if (typeof link === "string" || seen.has(link.pid)) break; + seen.add(link.pid); + chain.push(link); + current = link; + } + return chain; + }; + + const childEntry = context.childPid === null ? undefined : byPid.get(context.childPid); + const childAncestors = new Set(childEntry ? ancestors(childEntry).map(entry => entry.pid) : []); + const matchesPath = (entry: WindowsStopProcessEntry): boolean => + context.canonicalPaths.some(path => commandLineHasPathToken(entry.commandLine, path)); + + const unreadable = new Set(); + const candidates: WindowsStopProcessEntry[] = []; + for (const entry of processes) { + if (entry.pid === context.childPid || !matchesPath(entry)) continue; + if (entry.name === null) unreadable.add(entry.pid); + else if (windowsWrapperImage(entry.name)) candidates.push(entry); + } + + const bound = new Set(); + const descendsFromBound = (entry: WindowsStopProcessEntry): boolean => + ancestors(entry).some(ancestor => bound.has(ancestor.pid)); + const lineage = (entry: WindowsStopProcessEntry): boolean => { + if (childAncestors.has(entry.pid) || descendsFromBound(entry)) return true; + const link = parentLink(entry); + if (link === "gone") return context.taskRegistered; + if (link === "unreadable") return false; + return context.scheduleServicePid !== null && link.pid === context.scheduleServicePid; + }; + for (let changed = true; changed;) { + changed = false; + for (const entry of candidates) { + if (bound.has(entry.pid) || entry.startTime === null) continue; + if (lineage(entry)) { + bound.add(entry.pid); + changed = true; + } + } + } + + const unbound: WindowsStopBinding["unbound"] = []; + for (const entry of candidates) { + if (bound.has(entry.pid)) continue; + if (entry.startTime === null) unreadable.add(entry.pid); + else if (context.taskRegistered && parentLink(entry) === "unreadable") unreadable.add(entry.pid); + else unbound.push({ pid: entry.pid, startTime: entry.startTime }); + } + + for (const entry of processes) { + if (entry.pid === context.childPid || entry.commandLine?.trim() || !windowsWrapperImage(entry.name)) continue; + if (descendsFromBound(entry) || (context.managerRegistered && childAncestors.has(entry.pid))) { + unreadable.add(entry.pid); + } + } + + const boundEntries = candidates.filter(entry => bound.has(entry.pid)); + const depth = (entry: WindowsStopProcessEntry): number => + ancestors(entry).filter(ancestor => bound.has(ancestor.pid)).length; + boundEntries.sort((a, b) => depth(b) - depth(a) || a.pid - b.pid); + + return { + bound: boundEntries.map(entry => ({ + pid: entry.pid, + startTime: entry.startTime!, + image: windowsWrapperImage(entry.name)!, + parentPid: entry.parentPid, + commandLineDigest: digest(entry.commandLine ?? ""), + })), + unbound, + unreadable: [...unreadable].sort((a, b) => a - b), + child: childEntry ? { pid: childEntry.pid, startTime: childEntry.startTime } : null, + }; +} + +/** Wrapper-image processes still carrying this home's paths (Part B's final enumeration). */ +export function windowsHomeWrapperPids( + processes: readonly WindowsStopProcessEntry[], + canonicalPaths: readonly string[], + selfPid?: number, +): number[] { + return processes + .filter(entry => entry.pid !== selfPid && windowsWrapperImage(entry.name) + && canonicalPaths.some(path => commandLineHasPathToken(entry.commandLine, path))) + .map(entry => entry.pid); +} diff --git a/src/service/windows-stop-budget.ts b/src/service/windows-stop-budget.ts new file mode 100644 index 00000000000..ca937c6c851 --- /dev/null +++ b/src/service/windows-stop-budget.ts @@ -0,0 +1,123 @@ +/** + * Time budget of the checked Windows stop (#7771, 030 "Time budget"). + * + * One deadline governs the whole stop. Admission runs after Part A's registration probe + * and before anything changes: it adds up the worst case of every step still to come and + * refuses (unverified-budget, nothing changed) when the time left cannot hold it. Every + * step after admission that waits on the machine has a cap, and these constants are those + * caps. Opening the bind-time watches and the caller's authorizer are not capped; the + * authorizer runs inside the terminateAndWait window and is bounded by its caller. + * + * Pure arithmetic; the callers own the clock. + */ + +/** Caps of the individual steps, in ms. Part A and Part B enforce exactly these. */ +export const WINDOWS_STOP_CAPS = { + registrationProbe: 2_000, + enumeration: 5_000, + scheduleServicePid: 2_000, + taskEnd: 2_000, + winswStopCommand: 2_000, + winswPollMargin: 2_000, + /** Terminate calls and the bind-time handle wait share this window. */ + terminateAndWait: 3_000, + managerState: 1_000, + orphanDiscovery: 3_000, + postAnswer: 10_000, + forcedKill: 5_000, + forcedExitWait: 5_000, + childExitMargin: 3_000, + inheritedReceiptProbe: 3_000, + partBHoldFloor: 6_000, + partBWindowCeiling: 12_000, + /** + * A port sample may start at the very end of the window (the hold is measured between + * sample starts), so the closing sample can run this long past it. 030 did not count it. + */ + partBClosingSample: 500, + partBFinalEnumeration: 5_000, + reserve: 3_000, +} as const; + +/** The of the generated WinSW definition. */ +export const DEFAULT_WINSW_STOP_TIMEOUT_MS = 20_000; +/** shutdownTimeoutMs default the child exit row is computed from. */ +export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000; +/** Deadline of a caller that passes none (a terminal, a script, an older Desktop). */ +export const DEFAULT_WINDOWS_STOP_DEADLINE_MS = 60_000; + +export interface WindowsStopBudgetInput { + /** What Part A's registration probe found. */ + managers: { scheduler: boolean; winsw: boolean; stray: boolean }; + /** The bind reuses an enumeration that already ran (guarded: revalidateManager's). */ + enumerationReused: boolean; + /** Snapshotless stop without a PID file, or after WinSW stopped the proxy. */ + orphanDiscovery: boolean; + shutdownTimeoutMs?: number; + /** Abandoned teardown receipts whose endpoints are probed (two hosts each). */ + inheritedReceipts?: number; + winswStopTimeoutMs?: number; + /** Part B runs after Part A (false only where 030 skips it, such as the route). */ + partB?: boolean; +} + +export interface WindowsStopBudgetRow { + step: "part-a" | "orphan-discovery" | "post-answer" | "child-exit" | "inherited-receipts" | "part-b" | "reserve"; + ms: number; +} + +export interface WindowsStopBudget { + rows: WindowsStopBudgetRow[]; + totalMs: number; +} + +const C = WINDOWS_STOP_CAPS; + +/** Worst case of Part A still to come after its registration probe. */ +export function windowsPartAWorstCaseMs(input: Pick): number { + const { scheduler, winsw, stray } = input.managers; + const enumeration = input.enumerationReused ? 0 : C.enumeration; + let ms = 0; + if (scheduler) ms += enumeration + C.taskEnd + C.terminateAndWait + C.managerState; + if (winsw) { + const stopTimeout = input.winswStopTimeoutMs ?? DEFAULT_WINSW_STOP_TIMEOUT_MS; + // WinSW has no wrapper: no bind, no terminate. Its own stop ends the proxy within + // , so the SCM poll is capped at that plus a margin. + ms += C.winswStopCommand + stopTimeout + C.winswPollMargin + C.managerState; + } + // Nothing registered: the stray enumeration already ran in the registration step. + if (!scheduler && !winsw && stray) ms += C.terminateAndWait + C.managerState; + return ms; +} + +/** + * Part B's sampling window: min(12 s, remaining - closing sample - final enumeration - + * reserve), floor 6 s. + */ +export function windowsPartBWindowMs(remainingMs: number, reserveMs: number = C.reserve): number { + const room = remainingMs - C.partBClosingSample - C.partBFinalEnumeration - reserveMs; + return Math.max(C.partBHoldFloor, Math.min(C.partBWindowCeiling, room)); +} + +/** The worst case of everything still to come at admission, row by row. */ +export function windowsStopWorstCase(input: WindowsStopBudgetInput): WindowsStopBudget { + const { scheduler, winsw, stray } = input.managers; + // WinSW stops the proxy itself, so the POST and the child exit ladder do not apply to it. + const winswOnly = winsw && !scheduler && !stray; + const shutdown = input.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; + const rows: WindowsStopBudgetRow[] = [ + { step: "part-a", ms: windowsPartAWorstCaseMs(input) }, + ]; + if (input.orphanDiscovery) rows.push({ step: "orphan-discovery", ms: C.orphanDiscovery }); + if (!winswOnly) { + rows.push({ step: "post-answer", ms: C.postAnswer }); + rows.push({ step: "child-exit", ms: shutdown + C.childExitMargin + C.forcedKill + C.forcedExitWait }); + } + const receipts = input.inheritedReceipts ?? 0; + if (receipts > 0) rows.push({ step: "inherited-receipts", ms: receipts * C.inheritedReceiptProbe }); + if (input.partB ?? true) { + rows.push({ step: "part-b", ms: C.partBHoldFloor + C.partBClosingSample + C.partBFinalEnumeration }); + } + rows.push({ step: "reserve", ms: C.reserve }); + return { rows, totalMs: rows.reduce((sum, row) => sum + row.ms, 0) }; +} diff --git a/src/service/windows-stop-confirm.ts b/src/service/windows-stop-confirm.ts new file mode 100644 index 00000000000..38d2c9baa6b --- /dev/null +++ b/src/service/windows-stop-confirm.ts @@ -0,0 +1,158 @@ +/** + * Part B of the checked Windows service stop (#7771, 030 "Part B"), run by the caller + * after its graceful child stop: the port is free and stays free for one relaunch period, + * no wrapper of this home is running, and a remaining listener is classified. + * + * 1. Sample the port until it has been free for the 6 s hold, measured between sample + * starts, within the window from windowsPartBWindowMs (6-12 s, starting now). A loop that + * relaunches the proxy late breaks the streak. + * 2. Then, under one 5 s cap, run the final enumeration and, if the port is still held, + * identify its listener. The enumeration must show no wrapper-image process carrying + * this home's path token; an unreadable table is non-success, and a wrapper started + * after bind makes the stop survived even with the port free. + * 3. A remaining listener is ours (survived), foreign (portHeldByForeign) or left by an + * owner that has exited, which is not foreign: the caller's reclaim clears that one. + * + * Every effect is injected; nothing calls this yet. + */ +import type { WindowsStopProcessEntry } from "./windows-process-evidence"; +import { windowsHomeWrapperPids } from "./windows-stop-binding"; +import { runCapped, windowsStopClock, type WindowsStopClock } from "./windows-checked-stop"; +import { commandLineHasPathToken } from "./windows-guarded-manager"; +import { WINDOWS_STOP_CAPS, windowsPartBWindowMs } from "./windows-stop-budget"; + +type MaybePromise = T | Promise; + +export type WindowsPortSample = + | { state: "free" } + | { state: "listening"; ownerPid: number | null } + | { state: "unknown" }; + +/** ours = this installation's identity answers; exited = the owning process is gone. */ +export type WindowsListenerIdentity = "ours" | "foreign" | "exited" | "unknown"; + +export interface WindowsStopConfirmDeps { + clock?: WindowsStopClock; + port: number; + /** Time left in the caller's deadline when Part B starts. */ + remainingMs: number; + reserveMs?: number; + canonicalPaths: readonly string[]; + selfPid?: number; + samplePort(port: number, timeoutMs: number): MaybePromise; + identifyListener(port: number, ownerPid: number | null, timeoutMs: number): MaybePromise; + enumerate(timeoutMs: number): MaybePromise; +} + +export type WindowsStopConfirmOutcome = "freed" | "survived" | "port-held-by-foreign" | "unconfirmed" | "unknown"; + +export interface WindowsStopConfirmResult { + outcome: WindowsStopConfirmOutcome; + portHeldByForeign: boolean; + reason: "evidence-unreadable" | null; + detail: string; + port: number; + windowMs: number; + listener: { ownerPid: number | null; identity: WindowsListenerIdentity } | null; + /** Wrapper-image processes of this home found by the final enumeration. */ + lateWrappers: number[]; +} + +const C = WINDOWS_STOP_CAPS; +const SAMPLE_INTERVAL_MS = 250; + +export async function confirmWindowsStopFreed(deps: WindowsStopConfirmDeps): Promise { + const clock = deps.clock ?? windowsStopClock; + const windowMs = windowsPartBWindowMs(deps.remainingMs, deps.reserveMs ?? C.reserve); + const windowEnd = clock.now() + windowMs; + const result: WindowsStopConfirmResult = { + outcome: "unknown", portHeldByForeign: false, reason: null, detail: "", + port: deps.port, windowMs, listener: null, lateWrappers: [], + }; + const finish = (outcome: WindowsStopConfirmOutcome, detail: string): WindowsStopConfirmResult => { + result.outcome = outcome; + result.portHeldByForeign = outcome === "port-held-by-foreign"; + result.reason = outcome === "unknown" ? "evidence-unreadable" : null; + result.detail = detail; + return result; + }; + + let freeSince: number | null = null; + let held = false; + let last: WindowsPortSample = { state: "unknown" }; + for (;;) { + const startedAt = clock.now(); + const sample = await runCapped(clock, C.partBClosingSample, timeoutMs => deps.samplePort(deps.port, timeoutMs)); + last = sample.ok ? sample.value : { state: "unknown" }; + if (last.state === "free") { + freeSince ??= startedAt; + if (startedAt - freeSince >= C.partBHoldFloor) { + held = true; + break; + } + } else { + freeSince = null; + } + const left = windowEnd - clock.now(); + if (left <= 0) break; + await clock.sleep(Math.min(SAMPLE_INTERVAL_MS, left)); + } + + const listening = last.state === "listening" ? last : null; + const [snapshot, identity] = await Promise.all([ + runCapped(clock, C.partBFinalEnumeration, deps.enumerate), + listening + ? runCapped(clock, C.partBFinalEnumeration, + timeoutMs => deps.identifyListener(deps.port, listening.ownerPid, timeoutMs)) + : Promise.resolve(null), + ]); + if (listening) { + result.listener = { + ownerPid: listening.ownerPid, + identity: identity && identity.ok ? identity.value : "unknown", + }; + } + + const processes = snapshot.ok ? snapshot.value : null; + if (processes) { + result.lateWrappers = windowsHomeWrapperPids(processes, deps.canonicalPaths, deps.selfPid ?? process.pid); + } + if (result.lateWrappers.length > 0) return finish("survived", "a wrapper of this home is running after the stop"); + if (result.listener?.identity === "ours") return finish("survived", "this installation answers on the port again"); + if (processes === null) return finish("unknown", "the final process list could not be read"); + const unnamed = processes.some(entry => entry.name === null && entry.pid !== (deps.selfPid ?? process.pid) + && deps.canonicalPaths.some(path => commandLineHasPathToken(entry.commandLine, path))); + if (unnamed) return finish("unknown", "a process carrying this home's paths has an unreadable image name"); + if (held) return finish("freed", "the port stayed free for the hold and no wrapper of this home is running"); + if (result.listener?.identity === "foreign") return finish("port-held-by-foreign", "another program holds the port"); + if (result.listener?.identity === "exited") { + return finish("unconfirmed", "the port is held by a listener whose owner has exited"); + } + if (last.state === "free") return finish("unconfirmed", "the port was not free for the whole hold within the window"); + return finish("unknown", "the port state could not be read"); +} + +/** + * Which port Part B watches: the runtime-port record when present, else the port baked + * into the wrapper script, else the configured port. + */ +export function resolveWindowsStopPort(sources: { + runtimePort: () => number | null; + wrapperPort: () => number | null; + configuredPort: () => number; +}): { port: number; source: "runtime" | "wrapper" | "config" } { + const valid = (value: number | null): value is number => + value !== null && Number.isSafeInteger(value) && value > 0 && value <= 65_535; + const read = (get: () => number | null): number | null => { + try { + return get(); + } catch { + return null; + } + }; + const runtime = read(sources.runtimePort); + if (valid(runtime)) return { port: runtime, source: "runtime" }; + const wrapper = read(sources.wrapperPort); + if (valid(wrapper)) return { port: wrapper, source: "wrapper" }; + return { port: sources.configuredPort(), source: "config" }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index 13dbfcdec61..f7a9d3f6912 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -165,6 +165,7 @@ Background service, docs, release, and design discipline. | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | Durable Desktop ownership read, takeover-consent handling, live-supervision evidence, the Windows probe's trusted system-directory acquisition, and the supervision latch blocking contract consumed by service and update guards. | | [`ops/service-logging.md`](ops/service-logging.md) | Service-only console timestamps and bounded copy-truncate log retention. Producer bounds for combo positions, target providers and effort diagnostics. | | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | Opt-in sentinel that turns a hung but listening managed service proxy into an exit its manager restarts. | +| [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | Windows process evidence, the wrapper binding rule, and the checked manager stop and port confirmation that are not yet wired to a stop path. | | [`ops/update-supervised-recovery.md`](ops/update-supervised-recovery.md) | Manager-first recovery after a failed post-update service refresh, and the non-zero unsupervised fallback. | | [`ops/plugins.md`](ops/plugins.md) | Plugin loading from OPENCODEX_HOME/plugins and the upstream rewrite slot plugins attach to. | | [`ops/docs-and-release.md`](ops/docs-and-release.md) | Docs site, workflow map, branch policy, release flow, and cross-platform CI. | @@ -529,6 +530,12 @@ A source area can be described by more than one doc, because these docs are orga | `src/service/logging.ts` | [`ops/service-logging.md`](ops/service-logging.md) | | `src/service/orchestration.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | | `src/service/repair.ts` | [`ops/desktop-supervision.md`](ops/desktop-supervision.md) | +| `src/service/windows-checked-stop.ts` | [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | +| `src/service/windows-process-evidence.ts` | [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | +| `src/service/windows-process-native.ts` | [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | +| `src/service/windows-stop-binding.ts` | [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | +| `src/service/windows-stop-budget.ts` | [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | +| `src/service/windows-stop-confirm.ts` | [`ops/windows-checked-stop.md`](ops/windows-checked-stop.md) | | `src/sidecar/` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/sidecar/anthropic-binding.ts` | [`providers/anthropic-account-pool.md`](providers/anthropic-account-pool.md) | | `src/stall-timeout.ts` | [`transports/timeouts.md`](transports/timeouts.md)
[`runtime.md`](runtime.md) | diff --git a/structure/manifest.json b/structure/manifest.json index 4e7b9b0b34e..05dd11805cc 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -1303,6 +1303,20 @@ "src/service/health-sentinel-windows.ts" ] }, + { + "path": "ops/windows-checked-stop.md", + "tier": 6, + "title": "Windows Checked Service Stop", + "scope": "Windows process evidence, the wrapper binding rule, and the checked manager stop and port confirmation that are not yet wired to a stop path.", + "documents": [ + "src/service/windows-checked-stop.ts", + "src/service/windows-process-evidence.ts", + "src/service/windows-process-native.ts", + "src/service/windows-stop-binding.ts", + "src/service/windows-stop-budget.ts", + "src/service/windows-stop-confirm.ts" + ] + }, { "path": "ops/update-supervised-recovery.md", "tier": 6, diff --git a/structure/ops/windows-checked-stop.md b/structure/ops/windows-checked-stop.md new file mode 100644 index 00000000000..c6dc76270c9 --- /dev/null +++ b/structure/ops/windows-checked-stop.md @@ -0,0 +1,102 @@ +# Windows Checked Service Stop + +The checked Windows service stop (#7771) is a library of primitives and two operations that +stop a Task Scheduler or WinSW manager, end the wrappers that would relaunch the proxy, and +then confirm the port is free. **No stop path calls it yet.** `ocx stop`, `ocx service stop`, +`ocx uninstall`, service uninstall and install, `/api/stop` and the updater still use the +best-effort sweep in `src/lib/windows-service-wrappers.ts` and the guarded inspection in +`src/service/windows-guarded-manager.ts`, unchanged. The plan for wiring the callers lives in +`devlog/_plan/261111_windows_service_stop/`. + +## Process evidence + +`src/service/windows-process-native.ts` holds the in-process primitives for Windows x64. It loads +`kernel32.dll` and `advapi32.dll` from `resolveTrustedWindowsSystemDirectory()`, requires `bun:ffi` +lazily, caches a failed load as unavailable, and closes every handle in a `finally`: + +- creation time through `GetProcessTimes`, as epoch milliseconds truncated from the FILETIME; +- a checked terminate: `OpenProcess(PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE)`, + the creation time read on that handle and compared with the bound value, `TerminateProcess` on + the same handle. The result is `terminated`, `gone`, `reused` (identity mismatch, untouched), + `denied` or `unavailable`; +- an exit watch: a handle opened at bind time and polled with `WaitForSingleObject(handle, 0)`; +- the Task Scheduler service host PID through `QueryServiceStatusEx` on `Schedule`, only while it runs. + +Windows ARM64, other platforms and any test that simulates a platform get no native API. +`src/service/windows-process-evidence.ts` is the facade the operations use. Its process snapshot is +one capped `Get-CimInstance Win32_Process` query that emits `CreationDate` as an ISO UTC string. On +x64 each creation time is re-read natively and kept only when it agrees with CIM to the +millisecond; a disagreement (a PID reused between the two reads) is unreadable. Elsewhere the +CIM time is the bound value. The Schedule PID falls back to `sc.exe queryex Schedule` from the +trusted system directory with a cap. The ARM64 terminate is one trusted PowerShell script that +re-reads `Get-Process -Id` StartTime, compares it with the bound time within 1 s (UTC), calls +`Kill()` on the same `Process` object and reads the exit back. `Kill()` opens its own handle by PID, so +a window between the in-script check and the kill remains; that window and the 1 s tolerance are +the accepted residuals of the fallback. Only integers reach a script: a fractional timeout is +floored to whole milliseconds, and one that floors to 0 spawns nothing. + +With no ARM64 device run, 050 ships ARM64 with the strict variant (kill nothing, report `unknown`). +`terminateWindowsProcessBound` does not enforce that: on win32 without the native API it runs the +fallback script. The caller's terminate adapter gates strict itself; see +`devlog/_plan/261111_windows_service_stop/060_implementation.md`. + +## Binding rule + +`src/service/windows-stop-binding.ts` decides, as a pure function over a snapshot, which processes +may be terminated. A process is bound only when its image is `wscript.exe`, `cscript.exe` or +`cmd.exe`; its command line carries this home's launcher VBS or service script path as a complete +token; it is an ancestor of the identity-checked child, a descendant of a bound wrapper, a child +of the Schedule host (SCM PID), or, only while this home's task is registered, an orphan (a parent +that is missing or was created after it); and its creation time was read. The proxy child is +never a candidate. A wrapper that matches image and path but fails lineage is reported unbound and +left alone. Unreadable evidence about a candidate is reported unreadable: a matching wrapper +without a creation time or image name, and a wrapper image with no readable command line under a +bound wrapper or, while a manager is registered, above the child. Bound wrappers are ordered +deepest first and carry pid, creation time, image and a SHA-256 digest of the command line. + +## Part A and Part B + +`src/service/windows-checked-stop.ts` is Part A, `stopWindowsServiceChecked(deps, options)`. Every +external effect is injected. It probes registration (2 s each). An unreadable answer is `unknown` +with nothing changed. With nothing registered it enumerates once for a stray wrapper of a deleted +task; an unreadable table or no wrapper is `absent`. With a task registered or a stray sweep +running, it reads the Schedule host PID (2 s). Admission then compares the time left on the +caller's deadline, which already excludes these first steps, with the worst case still to come, +and returns `unverified-budget` without changing anything when it does not fit. It binds, ends +the task (`schtasks /end`, 2 s) or stops WinSW (a +2 s stop command and an SCM poll capped at `` + 2 s), terminates bound survivors +deepest first, waits on the bind-time watches (terminates and wait share 3 s), and reads the +manager state once (1 s). Denied stops every further step. A cap overrun is `unknown`. A wrapper or +manager still running is `survived` (`unbound-wrapper` when the survivor was never bound). WinSW +is `survived` while the proxy is still alive after the service reports stopped. The authorizer +variant `guardedStopWindowsServiceChecked` runs its authorizer at entry and before each terminate and +reports a refusal as `WindowsCheckedStopRefusedError` with what had already happened. + +Each step that asks the machine runs under a cap that races a timer and rechecks the elapsed time +on the injected clock. Opening the bind-time watches and the authorizer are not capped. The +authorizer runs inside the shared 3 s terminate-and-wait window, so its time comes out of that +window, and an authorizer that never returns hangs Part A; its caller bounds it. Part A has no +overall cap either, so a caller with a tighter bound than the sum of the caps (the `/api/stop` +route's 4 s sweep and 6 s Part A in 030) needs an outer race or smaller caps. + +`src/service/windows-stop-confirm.ts` is Part B, `confirmWindowsStopFreed(deps)`, run after the caller's +graceful child stop. The port must stay free for a 6 s hold, measured between sample starts, +inside a window of `min(12 s, remaining - 0.5 s - 5 s - reserve)` with a 6 s floor. A final +enumeration (5 s, sharing its cap with the listener identification) must show no wrapper image +with this home's path token. An unreadable table is not success. A listener is ours +(`survived`), foreign (`portHeldByForeign`) or left by an exited owner, which is not foreign. +`resolveWindowsStopPort` picks the runtime-port record, then the wrapper's baked port, then the +configured port. + +`src/service/windows-stop-budget.ts` holds the caps and the admission arithmetic. With default +settings the minimum is 48.5 s for a guarded Task Scheduler stop, 56.5 s for a snapshotless one +and 42.5 s for WinSW. Each includes the 0.5 s closing port sample that Part B can take past its +window. + +## Tests + +`tests/windows/windows-stop-binding.test.ts`, `tests/windows/windows-checked-stop.test.ts`, +`tests/windows/windows-stop-confirm.test.ts` and `tests/windows/windows-process-evidence.test.ts` are +hermetic: injected deps, a virtual clock and simulated platforms, and no real `schtasks`, +PowerShell or FFI call. The FFI primitive itself is exercised only by the cases gated on real +Windows x64. diff --git a/structure/runtime.md b/structure/runtime.md index 0a047af9a84..4c3f52164c9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -59,7 +59,7 @@ The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns `ocx resolve` (`src/cli/resolve.ts`) is the machine surface a desktop shell asks instead of resolving the config home, the port, and liveness itself: the home comes from `src/config/paths.ts`, the effective port is the live listener's when the identity-checked `findLiveProxy` answers and the configured `config.port ?? 10100` otherwise, and the liveness verdict is that same module's output (pid, runtime-versus-config provenance, version, role). Config reads go through `readConfigDiagnostics`, not `loadConfig`: a missing file is defaults, but an invalid file exits 1 instead of being repaired to defaults, because a defaulted port is a guess the caller must refuse. Liveness is three-valued: when `findLiveProxy` returns null, resolve re-asks the endpoints with `endpointsToProve` and `everyEndpointProvenDown`, using the in-process `probeEndpointLiveness` from `src/server/proxy-liveness.ts`. Only unanimous definitive "dead" becomes `absent-proven`; a timeout or reset stays unknown, exits 1 and never authorises a start. The transient exclusive-bind fallback in `src/update/proxy-liveness-probe.mjs` belongs only to the npm/Bun updater. Resolve stays in-process because a compiled standalone binary cannot run a JavaScript probe with `execPath -e`. Discovery borrows `START_OWNERSHIP_LIVENESS`, the start path's ownership budget — the verdict feeds the shell's launch decision, so the cost of a false "nobody listening" is the duplicate proxy (#5004). The verb is in `skipsCodexShimAutoRestore`, so a read-only lookup never triggers a shim repair. Arguments are pre-parsed in `src/cli/root.ts` and exit 64 before any preflight side effect, the same ordering `ocx ready` obeys. `ocx-resolve/1` also reports `ownership` (`none`, `owned`, or `unknown`) and `takeover` (`supported` with protocol version, minimum CLI version and compatibility token, or `blocked` with a reason). A known liveness verdict still exits 0 when ownership is unknown, but takeover is blocked. An unreadable second service-state read blocks takeover instead of becoming an absent registration. `src/service/managing-cli.ts` observes the registered and selected PATH CLIs, including Windows PATHEXT order and file validation, before compatibility is offered. A `--version` probe is retried once on Windows — one transient failure must not read as an unobservable CLI — and a Windows `.CMD`/`.BAT` shim is invoked through a single verbatim-quoted `cmd /c` line, so a spaced executable path and spaced arguments both survive cmd's re-parse. `src/cli/route-policy.ts` distinguishes a missing profile (exit 4) from malformed `show` arguments (exit 2); an upstream failure propagates through the management error mapping. -`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. The same guard re-reads `ocx.pid` and `runtime-port.json` through `src/config/tracked-runtime-inspection.ts`, a single-pass read-only probe that walks the same checks `readPid()` and `readRuntimePort(pid)` perform and reports which one failed, answering the recorded process's start-identity question through the same cache `readPid()` fills so it cannot disagree with the admission it diagnoses. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, the compatibility fingerprint, or the tracked-record cause — an absent, denied, unreadable or invalid record, a dead, foreign or unverifiable recorded process, or a record naming a different PID — so a transient re-probe can be told from genuine drift in the caller's log. The tracked-record causes are fixed strings: denied is reported only on an observed EACCES/EPERM, an unreadable command line stays unverifiable rather than attributed to an account or elevation, and no probed path, command line, stderr or token reaches the detail. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. +`ocx stop --json` is a reporting layer over the unchanged stop path. `src/cli/index.ts` threads a `StopRunRecord` through the existing receipt, drain, respawn-verification and restore flow, and `src/cli/stop-report.ts` maps the recorded facts plus the signals that already pick the exit code into one versioned document (`schema: "ocx-stop/1"`). With `--json` the human lines print on stderr and stdout carries only that document; exit codes 0/1/79/80/81 cross the process boundary unchanged. The desktop's opt-in guarded stop adds exact approved PID, endpoint, home, CLI version and compatibility-token expectations; plain `ocx stop` retains its ordinary behavior. Under the ownership mutation lease, `src/cli/stop-approval.ts` re-resolves them and `src/service/guarded-manager-target.ts` binds any installed manager's process tree to the approved PID before a stop action. The same guard re-reads `ocx.pid` and `runtime-port.json` through `src/config/tracked-runtime-inspection.ts`, a single-pass read-only probe that walks the same checks `readPid()` and `readRuntimePort(pid)` perform and reports which one failed, answering the recorded process's start-identity question through the same cache `readPid()` fills so it cannot disagree with the admission it diagnoses. A pre-action mismatch returns `approval-changed` without stopping, and the summary carries `detail` naming the single guard fact that failed — live PID/port/hostname/CLI drift, an unverifiable ownership claim, a blocked takeover, the compatibility fingerprint, or the tracked-record cause — an absent, denied, unreadable or invalid record, a dead, foreign or unverifiable recorded process, or a record naming a different PID — so a transient re-probe can be told from genuine drift in the caller's log. The tracked-record causes are fixed strings: denied is reported only on an observed EACCES/EPERM, an unreadable command line stays unverifiable rather than attributed to an account or elevation, and no probed path, command line, stderr or token reaches the detail. Immediately before any manager stop or direct PID signal, the CLI rechecks the same manager identity or proven absence; a change also returns `approval-changed`. A non-OpenCodex process replacing the OS job in the remaining instant before the manager command is a residual same-user risk outside the ownership lease. The guarded path stops the bound manager, or the approved PID directly when managers are proven absent, then uses a five-second shared deadline to verify PID exit, port availability and definitive endpoint absence. Only after settlement does a read-only manager probe require launchd `not-loaded` in both domains or systemd `inactive` with `MainPID=0`; the state is checked again before a success summary. Timeout, active or unreadable manager state returns terminal `manager-still-active`, even if the endpoint briefly refuses. Windows managers bind through `src/service/windows-guarded-manager.ts`: a Task Scheduler backend must prove a recognized OpenCodex registration against the bounded current-user SID/name lookup, a running task, and a wrapper ancestor carrying this home's canonical paths — presence and running state are read root-folder-scoped because every `/tn` operation resolves a bare name there and nowhere else, so a same-named task inside a task folder is a different registration neither the probe nor the stop can reach; the registered definition itself is read only on the running path, and a state or registration read that cannot answer re-probes presence so a task deleted between probes — which can respawn nothing — reads absent while a merely unreadable one stays unknown; an unreadable current identity leaves the manager unknown; a WinSW backend must prove this installation's binary and SCM service PID ancestry; either marks `childNeedsSeparateStop`, so the approved PID receives its own graceful signal only after a successful manager stop because `schtasks /end` does not reliably cascade to the wrapper's child (#764); a failed or unknown manager stop leaves the child untouched. Post-stop Windows inactivity means the task not running, no surviving wrapper process, and no started WinSW — never mere registration presence; a running-state read that cannot answer re-probes presence the same way, so a task deleted between probes reports inactive while a persisting one stays unknown. Every Windows manager command a verdict or stop depends on — schtasks, sc.exe, WinSW verbs — runs under a bounded `execFileSync` timeout so a wedged Task Scheduler or Service Control Manager cannot hang the guarded stop; a killed command classifies through the same fail-closed paths a nonzero exit would (the interactive `install /p` password prompt is the one deliberate exception). A `wscript.exe`, `cscript.exe` or `cmd.exe` whose command line CIM cannot read makes the state unprovable only when it is tied to the proxy: pre-stop, as an ancestor of the approved PID; post-stop, as the former bound manager process or a descendant of it. Unrelated unreadable shells, which a non-admin query returns for other users' and elevated processes, are not evidence either way. An unprovable Windows manager still blocks guarded takeover. The token checks snapshot consistency, not whether a person approved the desktop prompt. `src/cli/stop-restore.ts` owns the shared Codex/Grok restoration and its history-only, history-deferred, and other failure categories. The checked Windows stop that will replace the best-effort wrapper sweep and bind a stray wrapper by lineage and creation time is described in [Windows checked service stop](ops/windows-checked-stop.md); no stop path calls it yet. `ocx service status --json` (`src/service/status-json.ts`) is the passive machine form of the service report, read by peers such as GOAT's device inventory. `src/service/cli.ts` accepts `--json` for `status` only (`planServiceCommand` refuses it on every other verb before any installation probe) and answers it before `platformOps()` resolves, so no install, start, stop, repair or migration path is reachable; `skipsCodexShimAutoRestore` exempts `service status --json` so the CLI preflight repairs no Codex shim first, the collection runs inside `runPassiveRead` so the serving probe's config load neither hardens modes nor backs up a malformed file, and `--json` on any other verb is refused before the Desktop check and the Windows mutation lock; an unsupported platform or Docker gets a document instead of the text path's exit. The document reuses exactly the reads the text report makes — `diagnoseService()` (launchd `print`, systemd observation verbs, Task Scheduler `/query`, SCM/WinSW status) and, only when a registration exists, the 1.5-second loopback `confirmServiceServing` probe — plus the ownership-lease and Desktop login lines. `running` is the manager's verdict (the value `startup.serviceRunning` carries in `ocx status --json`); `serving` reports separately whether a proxy answers on the installed port. `backend` is `launchd`, `systemd`, `schtasks`, `winsw`, or `none` when nothing is registered. The systemd branch of `diagnoseService` derives through the pure `deriveSystemdServiceDiagnostic`, matching the launchd and Windows derivations. `tests/service/service-status-json.test.ts` snapshots each platform branch and records every subprocess entry point on darwin, linux and win32 against a read-only allowlist. `ocx status --json` carries an additive `install` verdict (`src/cli/status-install.ts`) mapped from the updater's own `detectInstallOwnership()`: `method` is `bun`, `npm`, `pnpm`, `mise`, `source`, `standalone` or `unknown` (detector failure), and `evidence.basis` names the package layout, the mise backend record (tool alias and backend only) or the standalone binary without printing a path (`tests/cli/status-install-method.test.ts`). `ocx service restart` reads the pid of the proxy answering on the installed port before it restarts (`servingRuntimePid`, `src/service/health.ts`) and passes it to the serving check as `replacing`. While that pid is set, only a proxy reporting a different pid counts as serving: a Windows wrapper that survived `schtasks /end` keeps its old child answering healthz, and the restart used to report "restarted and serving" over it (#7771). If the replaced pid is still the one answering at the deadline, the command prints that the previous runtime is still serving and the replacement was not confirmed, prints no checkmark, and exits 1; a restart that threw names either the surviving pid (kept, not replaced) or the new pid. An answer that reports no pid cannot be shown to be the old runtime and counts as serving. `ocx service repair` keeps the plain any-proxy check, because a repair may preserve a healthy runtime by design. `tests/service/service-restart-replacement.test.ts` covers each case. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8b3b5f6cc4d..e63f42563bd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1528,8 +1528,8 @@ "windows-deploy-close-regressions.test.ts": "windows", "windows-elevation-spawn.test.ts": "windows", "windows-elevation.test.ts": "windows", - "windows-guarded-manager.test.ts": "windows", - "windows-popup-fix.test.ts": "windows", + "windows-guarded-manager.test.ts": "windows", "windows-checked-stop.test.ts": "windows", "windows-stop-binding.test.ts": "windows", + "windows-popup-fix.test.ts": "windows", "windows-stop-confirm.test.ts": "windows", "windows-process-evidence.test.ts": "windows", "windows-process-priority.test.ts": "windows", "windows-scheduler-errors.test.ts": "windows", "windows-scheduler-install-verification.test.ts": "windows", "windows-lifecycle-root-acl.test.ts": "windows", diff --git a/tests/helpers/windows-checked-stop-fixtures.ts b/tests/helpers/windows-checked-stop-fixtures.ts new file mode 100644 index 00000000000..a78a248ccc6 --- /dev/null +++ b/tests/helpers/windows-checked-stop-fixtures.ts @@ -0,0 +1,56 @@ +/** + * Shared fixtures for the checked Windows stop tests: a supervised process tree and a + * virtual clock. The clock advances only when a sleep is not aborted, so a capped step + * that answers in time costs no virtual time and a hung one costs exactly its cap. + */ +import type { WindowsStopProcessEntry } from "../../src/service/windows-process-evidence"; +import type { WindowsStopClock } from "../../src/service/windows-checked-stop"; + +export const HOME = "C:\\Users\\me\\.opencodex"; +export const VBS = HOME + "\\opencodex-service-launcher.vbs"; +export const CMD = HOME + "\\opencodex-service.cmd"; +export const OTHER_HOME_VBS = "C:\\Users\\other\\.opencodex\\opencodex-service-launcher.vbs"; +export const PATHS = [VBS, CMD] as const; +export const SCHEDULE_PID = 900; + +export const entry = ( + pid: number, + parentPid: number | null, + name: string | null, + commandLine: string | null, + startTime: number | null, +): WindowsStopProcessEntry => ({ pid, parentPid, name, commandLine, startTime }); + +export const scheduleHost = entry(SCHEDULE_PID, 600, "svchost.exe", "C:\\Windows\\system32\\svchost.exe -k netsvcs -s Schedule", 1_000); +export const launcher = (pid = 20, parent = SCHEDULE_PID, start: number | null = 5_000) => + entry(pid, parent, "wscript.exe", 'C:\\Windows\\System32\\wscript.exe //B //NoLogo "' + VBS + '"', start); +export const loop = (pid = 30, parent = 20, start: number | null = 5_100) => + entry(pid, parent, "cmd.exe", 'C:\\Windows\\System32\\cmd.exe /c "' + CMD + '"', start); +export const proxy = (pid = 42, parent = 30, start = 5_200) => + entry(pid, parent, "bun.exe", "C:\\bun\\bun.exe C:\\pkg\\src\\cli\\index.ts start --port 10100", start); + +/** svchost(Schedule) 900 -> wscript 20 (.vbs) -> cmd 30 (.cmd) -> proxy 42. */ +export const supervisedTree = (): WindowsStopProcessEntry[] => [scheduleHost, launcher(), loop(), proxy()]; + +export interface VirtualClock { + clock: WindowsStopClock; + advance(ms: number): void; + readonly now: number; +} + +export function virtualClock(start = 0): VirtualClock { + let now = start; + return { + clock: { + now: () => now, + sleep: (ms, signal) => new Promise(resolve => { + setTimeout(() => { + if (!signal?.aborted) now += Math.max(0, ms); + resolve(); + }, 0); + }), + }, + advance(ms) { now += ms; }, + get now() { return now; }, + }; +} diff --git a/tests/windows/windows-checked-stop.test.ts b/tests/windows/windows-checked-stop.test.ts new file mode 100644 index 00000000000..9580c9a7d84 --- /dev/null +++ b/tests/windows/windows-checked-stop.test.ts @@ -0,0 +1,424 @@ +// Part A of the checked Windows stop (#7771, 030 "Steps" and "Time budget"). Hermetic: +// every probe, command, watch and terminate is injected; nothing reaches the machine. +import { describe, expect, test } from "bun:test"; + +import type { WinswStatus } from "../../src/lib/winsw"; +import { + guardedStopWindowsServiceChecked, + stopWindowsServiceChecked, + WindowsCheckedStopRefusedError, + type WindowsCheckedStopDeps, + type WindowsWinswScmState, +} from "../../src/service/windows-checked-stop"; +import type { WindowsProcessWatch, WindowsTerminateResult } from "../../src/service/windows-process-native"; +import type { WindowsStopProcessEntry } from "../../src/service/windows-process-evidence"; +import { + DEFAULT_WINSW_STOP_TIMEOUT_MS, + windowsPartBWindowMs, + windowsStopWorstCase, +} from "../../src/service/windows-stop-budget"; +import { + PATHS, SCHEDULE_PID, entry, launcher, loop, scheduleHost, supervisedTree, virtualClock, +} from "../helpers/windows-checked-stop-fixtures"; + +const never = (): Promise => new Promise(() => undefined); + +interface Harness { + deps: WindowsCheckedStopDeps; + calls: string[]; + alive: Set; + clock: ReturnType; +} + +function harness(overrides: Partial = {}, snapshot: WindowsStopProcessEntry[] = supervisedTree()): Harness { + const calls: string[] = []; + const alive = new Set(snapshot.map(process => process.pid)); + const clock = virtualClock(1_000); + const watch = (pid: number): WindowsProcessWatch => alive.has(pid) + ? { status: "open", exited: () => !alive.has(pid), close: () => calls.push("close:" + pid) } + : { status: "gone" }; + const deps: WindowsCheckedStopDeps = { + clock: clock.clock, + canonicalPaths: PATHS, + childPid: 42, + selfPid: 1, + probeScheduler: () => { calls.push("probe-scheduler"); return { status: "present" }; }, + probeWinsw: () => { calls.push("probe-winsw"); return "nonexistent"; }, + enumerate: () => { calls.push("enumerate"); return snapshot; }, + scheduleServicePid: () => SCHEDULE_PID, + endTask: () => { calls.push("end"); return "ended"; }, + taskState: () => "not-running", + winswStop: () => { calls.push("winsw-stop"); return "sent"; }, + winswState: () => "stopped", + proxyAlive: () => false, + openWatch: pid => watch(pid), + terminate: (pid): WindowsTerminateResult => { + calls.push("terminate:" + pid); + alive.delete(pid); + return { status: "terminated" }; + }, + ...overrides, + }; + return { deps, calls, alive, clock }; +} + +const mutations = (calls: string[]) => calls.filter(call => call === "end" || call === "winsw-stop" || call.startsWith("terminate:")); + +describe("checked Windows stop, part A", () => { + test("/end leaves the loop alive: the wrappers are terminated deepest first and the child is left alone", async () => { + const h = harness(); + const result = await stopWindowsServiceChecked(h.deps); + expect(result.outcome).toBe("stopped"); + expect(result.reason).toBeNull(); + expect(mutations(h.calls)).toEqual(["end", "terminate:30", "terminate:20"]); + expect(h.calls).not.toContain("terminate:42"); + expect(h.alive.has(42)).toBe(true); + expect(result.bound.map(wrapper => [wrapper.pid, wrapper.startTime])).toEqual([[30, 5_100], [20, 5_000]]); + expect(result.terminated).toEqual([30, 20]); + expect(result.changed).toBe(true); + // Every bind-time handle is closed. + expect(h.calls.filter(call => call.startsWith("close:")).sort()).toEqual(["close:20", "close:30"]); + }); + + test("a PID reused between bind and terminate is left untouched", async () => { + const h = harness(); + h.deps.terminate = pid => { + h.calls.push("terminate:" + pid); + if (pid === 30) return { status: "reused" }; + h.alive.delete(pid); + return { status: "terminated" }; + }; + // The bound cmd.exe is gone; PID 30 now names a different process, still running. + h.deps.openWatch = (pid): WindowsProcessWatch => ({ + status: "open", exited: () => pid === 30 ? false : !h.alive.has(pid), close: () => undefined, + }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result.terminated).toEqual([20]); + expect(h.alive.has(30)).toBe(true); + expect(result.outcome).toBe("stopped"); + }); + + test("access denied on /end is denied and nothing is terminated", async () => { + const h = harness({ endTask: () => "denied" }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "denied", reason: "access-denied" }); + expect(h.calls.filter(call => call.startsWith("terminate:"))).toEqual([]); + }); + + test("access denied on a terminate stops every further kill", async () => { + const h = harness(); + h.deps.terminate = pid => { h.calls.push("terminate:" + pid); return { status: "denied" }; }; + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "denied", reason: "access-denied" }); + expect(h.calls.filter(call => call.startsWith("terminate:"))).toEqual(["terminate:30"]); + }); + + test("an unreadable candidate under the bound manager is unknown and nothing changes", async () => { + const hidden = entry(35, 20, "cmd.exe", null, 5_300); + const h = harness({}, [...supervisedTree(), hidden]); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(mutations(h.calls)).toEqual([]); + }); + + test("an unreadable creation time on a wrapper is unknown and nothing changes", async () => { + const h = harness({}, [scheduleHost, launcher(), loop(30, 20, null), entry(42, 30, "bun.exe", "bun", 5_200)]); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(mutations(h.calls)).toEqual([]); + }); + + test("an unreadable process list under a registered task is unknown, never absent", async () => { + const h = harness({ enumerate: () => null }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(mutations(h.calls)).toEqual([]); + }); + + test("a wrapper with an unreadable parent PID under a registered task is unknown, never unbound", async () => { + const h = harness({ childPid: null }, [scheduleHost, { ...launcher(), parentPid: null }, loop()]); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(mutations(h.calls)).toEqual([]); + }); + + test("a denied exit watch at bind is denied before the task is ended", async () => { + const h = harness(); + const open = h.deps.openWatch; + h.deps.openWatch = (pid, start) => (pid === 30 ? { status: "denied" } : open(pid, start)); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "denied", reason: "access-denied", changed: false }); + expect(mutations(h.calls)).toEqual([]); + }); + + test("a terminate that overruns its cap ends part A as unknown with no further terminate", async () => { + const h = harness(); + h.deps.terminate = pid => { h.calls.push("terminate:" + pid); return never(); }; + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: true }); + expect(mutations(h.calls)).toEqual(["end", "terminate:30"]); + }); + + test("an unreadable task state after the stop is unknown, never stopped", async () => { + const unreadable = harness({ taskState: () => "unknown" }); + expect(await stopWindowsServiceChecked(unreadable.deps)) + .toMatchObject({ outcome: "unknown", reason: "evidence-unreadable" }); + const failed = harness({ taskState: () => { throw new Error("schtasks failed"); } }); + expect(await stopWindowsServiceChecked(failed.deps)) + .toMatchObject({ outcome: "unknown", reason: "evidence-unreadable" }); + }); + + test("an unreadable WinSW state after the stop is unknown, never stopped", async () => { + const finalState = (last: () => WindowsWinswScmState | Promise) => { + let reads = 0; + // The SCM poll sees stopped; the one manager-state read after the wait cannot tell. + return () => (reads++ === 0 ? "stopped" : last()); + }; + for (const last of [() => "unknown" as const, () => never()]) { + const h = harness({ + probeScheduler: () => ({ status: "absent" }), + probeWinsw: () => "started" as WinswStatus, + winswState: finalState(last), + }); + expect(await stopWindowsServiceChecked(h.deps)) + .toMatchObject({ outcome: "unknown", reason: "evidence-unreadable" }); + } + }); + + test("a stray wrapper with the task not running is bound through the Schedule host and ended", async () => { + const h = harness({ childPid: null }, [scheduleHost, launcher(), loop()]); + const result = await stopWindowsServiceChecked(h.deps); + expect(result.outcome).toBe("stopped"); + expect(mutations(h.calls)).toEqual(["end", "terminate:30", "terminate:20"]); + }); + + test("an orphan is ended while the task is registered and left alone once it is not", async () => { + const orphans = [launcher(20, 777), loop()]; + const registered = harness({ childPid: null }, orphans); + expect((await stopWindowsServiceChecked(registered.deps)).outcome).toBe("stopped"); + expect(registered.calls).toContain("terminate:20"); + + const deleted = harness({ childPid: null, probeScheduler: () => ({ status: "absent" }) }, orphans); + const result = await stopWindowsServiceChecked(deleted.deps); + expect(result).toMatchObject({ outcome: "survived", reason: "unbound-wrapper", changed: false }); + expect(result.unbound.sort()).toEqual([20, 30]); + expect(mutations(deleted.calls)).toEqual([]); + }); + + test("a hand-started launcher that fails lineage is not terminated: survived, unbound-wrapper", async () => { + const shell = entry(500, 4, "explorer.exe", "explorer.exe", 2_000); + const tree = [...supervisedTree(), shell, launcher(60, 500, 7_000), loop(61, 60, 7_100)]; + const h = harness({}, tree); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "survived", reason: "unbound-wrapper" }); + expect(h.calls).not.toContain("terminate:60"); + expect(h.calls).not.toContain("terminate:61"); + expect(result.unbound.sort()).toEqual([60, 61]); + }); + + // Without a native API (ARM64, or no FFI) every watch answers "unavailable". The wrappers are + // still bound and terminated, but nothing proves they exited, so the stop is unknown, never stopped. + test("unavailable exit watches still terminate the bound wrappers and end as unknown", async () => { + const h = harness({ openWatch: () => ({ status: "unavailable" }) }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: true }); + expect(mutations(h.calls)).toEqual(["end", "terminate:30", "terminate:20"]); + expect(result.terminated).toEqual([30, 20]); + }); + + // The answer slice 2's strict ARM64 adapter returns: never counted as an exit. + test("a terminate that answers unavailable is unknown and nothing further is terminated", async () => { + const h = harness({ terminate: (pid): WindowsTerminateResult => { h.calls.push("terminate:" + pid); return { status: "unavailable" }; } }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: true }); + expect(mutations(h.calls)).toEqual(["end", "terminate:30"]); + expect(result.terminated).toEqual([]); + }); + + test("an unbound wrapper whose exit cannot be watched still counts as running", async () => { + const shell = entry(500, 4, "explorer.exe", "explorer.exe", 2_000); + const tree = [...supervisedTree(), shell, launcher(60, 500, 7_000), loop(61, 60, 7_100)]; + const base = harness({}, tree); + const open = base.deps.openWatch; + base.deps.openWatch = (pid, start) => (pid === 60 || pid === 61 ? { status: "unavailable" } : open(pid, start)); + expect(await stopWindowsServiceChecked(base.deps)).toMatchObject({ outcome: "survived", reason: "unbound-wrapper" }); + + // A wrapper of this home with no creation time is unreadable evidence: unknown, nothing changed. + const unwatched = harness({}, [...supervisedTree(), shell, { ...launcher(60, 500, 7_000), startTime: null }]); + const result = await stopWindowsServiceChecked(unwatched.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(mutations(unwatched.calls)).toEqual([]); + }); + + test("a bound wrapper that outlives its terminate is survived", async () => { + const h = harness(); + h.deps.terminate = pid => { h.calls.push("terminate:" + pid); return { status: "terminated" }; }; + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "survived", reason: null }); + // The wait is capped: 3 s of virtual time, then the verdict. + expect(h.clock.now - 1_000).toBeLessThanOrEqual(3_000 + 100); + }); + + test("a task still running after the stop is survived", async () => { + const h = harness({ taskState: () => "running" }); + expect((await stopWindowsServiceChecked(h.deps)).outcome).toBe("survived"); + }); + + test("WinSW: a non-waiting stop, then survived when the proxy is still alive", async () => { + const h = harness({ + probeScheduler: () => ({ status: "absent" }), + probeWinsw: () => "started" as WinswStatus, + proxyAlive: () => true, + }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result.outcome).toBe("survived"); + expect(mutations(h.calls)).toEqual(["winsw-stop"]); + expect(h.calls).not.toContain("enumerate"); + }); + + test("WinSW: an SCM state that never reports stopped is unknown after + 2 s", async () => { + const states: WindowsWinswScmState[] = []; + const h = harness({ + probeScheduler: () => ({ status: "absent" }), + probeWinsw: () => "started" as WinswStatus, + winswState: () => { states.push("pending"); return "pending"; }, + }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: true }); + const elapsed = h.clock.now - 1_000; + expect(elapsed).toBeGreaterThanOrEqual(DEFAULT_WINSW_STOP_TIMEOUT_MS + 2_000); + expect(elapsed).toBeLessThanOrEqual(DEFAULT_WINSW_STOP_TIMEOUT_MS + 2_000 + 250); + expect(states.length).toBeGreaterThan(1); + }); + + test("a registration probe that never answers is unknown and changes nothing", async () => { + const h = harness({ probeScheduler: () => never() }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(result.registration.scheduler).toBe("unknown"); + expect(mutations(h.calls)).toEqual([]); + }); + + test("an unreadable schtasks answer stays unknown and never becomes absent", async () => { + const h = harness({ probeScheduler: () => ({ status: "unknown", detail: "query failed" }) }); + expect((await stopWindowsServiceChecked(h.deps)).outcome).toBe("unknown"); + }); + + test("/end that overruns its cap is unknown and nothing is terminated", async () => { + const h = harness({ endTask: () => { h.calls.push("end"); return never(); } }); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", changed: true }); + expect(h.calls.filter(call => call.startsWith("terminate:"))).toEqual([]); + }); + + test("a dependency that blocks past its cap is an overrun even though it answered", async () => { + const h = harness(); + h.deps.endTask = () => { h.clock.advance(2_500); return "ended"; }; + const result = await stopWindowsServiceChecked(h.deps); + expect(result.outcome).toBe("unknown"); + expect(h.calls.filter(call => call.startsWith("terminate:"))).toEqual([]); + }); + + test("admission refuses before anything changes when the worst case does not fit", async () => { + const budget = { orphanDiscovery: false }; + const guardedMinimum = windowsStopWorstCase({ + managers: { scheduler: true, winsw: false, stray: false }, enumerationReused: true, orphanDiscovery: false, + }).totalMs; + const refused = harness(); + const result = await stopWindowsServiceChecked(refused.deps, { + bindSnapshot: supervisedTree(), + admission: { deadlineAt: refused.clock.now + guardedMinimum - 1, budget }, + }); + expect(result).toMatchObject({ outcome: "unverified-budget", reason: "unverified-budget", changed: false }); + expect(result.budget).toEqual({ requiredMs: guardedMinimum, remainingMs: guardedMinimum - 1 }); + expect(mutations(refused.calls)).toEqual([]); + expect(refused.calls).not.toContain("enumerate"); + + const admitted = harness(); + const ok = await stopWindowsServiceChecked(admitted.deps, { + bindSnapshot: supervisedTree(), + admission: { deadlineAt: admitted.clock.now + guardedMinimum, budget }, + }); + expect(ok.outcome).toBe("stopped"); + expect(admitted.calls).not.toContain("enumerate"); + }); + + test("nothing registered is absent and is never refused for budget", async () => { + const nothing = harness({ probeScheduler: () => ({ status: "absent" }) }, [scheduleHost, entry(42, 10, "bun.exe", "bun", 5_200)]); + const result = await stopWindowsServiceChecked(nothing.deps, { + admission: { deadlineAt: 0, budget: { orphanDiscovery: true } }, + }); + expect(result).toMatchObject({ outcome: "absent", changed: false }); + + const unreadable = harness({ probeScheduler: () => ({ status: "absent" }), enumerate: () => null }); + expect((await stopWindowsServiceChecked(unreadable.deps)).outcome).toBe("absent"); + }); + + test("a stray wrapper of a deleted task is bound through the Schedule host and swept", async () => { + const h = harness({ childPid: null, probeScheduler: () => ({ status: "absent" }) }, [scheduleHost, launcher(), loop()]); + const result = await stopWindowsServiceChecked(h.deps); + expect(result.outcome).toBe("stopped"); + expect(mutations(h.calls)).toEqual(["terminate:30", "terminate:20"]); + }); + + test("with nothing registered, an unreadable stray candidate is unknown, never absent", async () => { + const h = harness({ childPid: null, probeScheduler: () => ({ status: "absent" }) }, [scheduleHost, launcher(20, SCHEDULE_PID, null)]); + const result = await stopWindowsServiceChecked(h.deps); + expect(result).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", changed: false }); + expect(mutations(h.calls)).toEqual([]); + }); + + test("the updater's authorizer refuses at entry before any probe", async () => { + const h = harness(); + const refusal = new Error("supervision blocked"); + const error = await guardedStopWindowsServiceChecked(() => { throw refusal; }, h.deps).catch(caught => caught); + expect(error).toBeInstanceOf(WindowsCheckedStopRefusedError); + expect((error as WindowsCheckedStopRefusedError).cause).toBe(refusal); + expect((error as WindowsCheckedStopRefusedError).partial.changed).toBe(false); + expect(h.calls).toEqual([]); + }); + + test("the updater's authorizer runs again before each terminate and its refusal ends the stop", async () => { + const h = harness(); + let checks = 0; + const error = await guardedStopWindowsServiceChecked(() => { + checks += 1; + if (checks === 3) throw new Error("respawned runtime"); + }, h.deps).catch(caught => caught); + expect(error).toBeInstanceOf(WindowsCheckedStopRefusedError); + expect((error as WindowsCheckedStopRefusedError).partial.terminated).toEqual([30]); + expect(h.calls.filter(call => call.startsWith("terminate:"))).toEqual(["terminate:30"]); + }); +}); + +describe("checked Windows stop budget", () => { + test("admission minimums with default settings", () => { + // 030's 48 / 56 / 42 s plus the 0.5 s closing sample part B can take past its window. + expect(windowsStopWorstCase({ + managers: { scheduler: true, winsw: false, stray: false }, enumerationReused: true, orphanDiscovery: false, + }).totalMs).toBe(48_500); + expect(windowsStopWorstCase({ + managers: { scheduler: true, winsw: false, stray: false }, enumerationReused: false, orphanDiscovery: true, + }).totalMs).toBe(56_500); + expect(windowsStopWorstCase({ + managers: { scheduler: false, winsw: true, stray: false }, enumerationReused: false, orphanDiscovery: true, + }).totalMs).toBe(42_500); + }); + + test("receipts, a longer shutdown and a longer WinSW stop timeout grow the worst case", () => { + const base = { managers: { scheduler: true, winsw: false, stray: false }, enumerationReused: true, orphanDiscovery: false }; + expect(windowsStopWorstCase({ ...base, inheritedReceipts: 2 }).totalMs).toBe(48_500 + 6_000); + expect(windowsStopWorstCase({ ...base, shutdownTimeoutMs: 10_000 }).totalMs).toBe(48_500 + 5_000); + expect(windowsStopWorstCase({ + managers: { scheduler: false, winsw: true, stray: false }, enumerationReused: false, orphanDiscovery: false, + winswStopTimeoutMs: 30_000, + }).rows.find(row => row.step === "part-a")!.ms).toBe(35_000); + expect(windowsStopWorstCase({ ...base, partB: false }).rows.map(row => row.step)).not.toContain("part-b"); + }); + + test("part B's window is min(12 s, remaining minus its tail and the reserve), never under 6 s", () => { + expect(windowsPartBWindowMs(60_000)).toBe(12_000); + expect(windowsPartBWindowMs(20_000)).toBe(11_500); + expect(windowsPartBWindowMs(10_000)).toBe(6_000); + }); +}); diff --git a/tests/windows/windows-process-evidence.test.ts b/tests/windows/windows-process-evidence.test.ts new file mode 100644 index 00000000000..82430294723 --- /dev/null +++ b/tests/windows/windows-process-evidence.test.ts @@ -0,0 +1,228 @@ +// Process evidence for the checked Windows stop (#7771): creation times, the Schedule +// service PID, the checked terminate and the exit watch. Hermetic everywhere except the +// cases gated on real Windows x64, which exercise the FFI primitive on a child of this test. +import { afterEach, describe, expect, test } from "bun:test"; + +import { + decodeServiceStatusProcess, + fileTimeToEpochMs, + setWindowsProcessNativeForTests, + setWindowsProcessPlatformForTests, + windowsProcessNative, + type WindowsProcessNativeApi, +} from "../../src/service/windows-process-native"; +import { + WINDOWS_PROCESS_SNAPSHOT_SCRIPT, + openWindowsProcessWatch, + overlayNativeCreationTimes, + parseFallbackTerminateOutput, + parseScQueryexPid, + parseWindowsProcessSnapshot, + readScheduleServicePid, + terminateWindowsProcessBound, + windowsFallbackTerminateScript, + windowsProcessSnapshot, + type WindowsEvidenceCommand, +} from "../../src/service/windows-process-evidence"; + +afterEach(() => { + setWindowsProcessNativeForTests(undefined); + setWindowsProcessPlatformForTests(null); +}); + +const fakeNative = (overrides: Partial = {}): WindowsProcessNativeApi => ({ + creationTime: () => null, + terminate: () => ({ status: "unavailable" }), + openWatch: () => ({ status: "unavailable" }), + scheduleServicePid: () => null, + ...overrides, +}); + +const SNAPSHOT_JSON = JSON.stringify([ + { ProcessId: 20, ParentProcessId: 900, Name: "wscript.exe", CommandLine: "wscript.exe x.vbs", CreationDate: "2026-11-12T01:02:03.1234567Z" }, + { ProcessId: 4, ParentProcessId: 0, Name: "System", CommandLine: null, CreationDate: null }, +]); + +describe("Windows process evidence: pure decoding", () => { + test("FILETIME converts to epoch ms by truncation; zero is unreadable", () => { + expect(fileTimeToEpochMs(116_444_736_000_000_000n + 12_345_678n)).toBe(1_234); + expect(fileTimeToEpochMs(0n)).toBeNull(); + expect(fileTimeToEpochMs(116_444_736_000_000_000n)).toBeNull(); + }); + + test("SERVICE_STATUS_PROCESS yields the PID only while the service runs", () => { + const buffer = new Uint8Array(36); + const view = new DataView(buffer.buffer); + view.setUint32(4, 4, true); + view.setUint32(28, 1_234, true); + expect(decodeServiceStatusProcess(buffer)).toBe(1_234); + view.setUint32(4, 1, true); + expect(decodeServiceStatusProcess(buffer)).toBeNull(); + expect(decodeServiceStatusProcess(new Uint8Array(20))).toBeNull(); + }); + + test("the snapshot carries ISO creation times as epoch ms; malformed output is unread", () => { + expect(parseWindowsProcessSnapshot(SNAPSHOT_JSON)).toEqual([ + { pid: 20, parentPid: 900, name: "wscript.exe", commandLine: "wscript.exe x.vbs", startTime: Date.parse("2026-11-12T01:02:03.123Z") }, + { pid: 4, parentPid: null, name: "System", commandLine: null, startTime: null }, + ]); + expect(parseWindowsProcessSnapshot(JSON.stringify({ ProcessId: 7, ParentProcessId: 1, Name: "a", CommandLine: "a", CreationDate: "x" }))) + .toEqual([{ pid: 7, parentPid: 1, name: "a", commandLine: "a", startTime: null }]); + expect(parseWindowsProcessSnapshot("")).toEqual([]); + expect(parseWindowsProcessSnapshot("{not json")).toBeNull(); + }); + + test("a native creation time replaces CIM's only when both agree to the millisecond", () => { + const rows = parseWindowsProcessSnapshot(SNAPSHOT_JSON)!; + const cim = rows[0]!.startTime!; + expect(overlayNativeCreationTimes(rows, () => cim + 1)[0]!.startTime).toBe(cim + 1); + // A PID reused between the CIM query and the native read: the identities disagree. + expect(overlayNativeCreationTimes(rows, () => cim + 2_000)[0]!.startTime).toBeNull(); + expect(overlayNativeCreationTimes(rows, () => null)[0]!.startTime).toBeNull(); + expect(overlayNativeCreationTimes(rows, () => { throw new Error("ffi"); })[0]!.startTime).toBeNull(); + }); + + test("sc.exe queryex yields the PID only for a RUNNING service", () => { + const running = "SERVICE_NAME: Schedule\r\n TYPE : 30 WIN32\r\n STATE : 4 RUNNING\r\n" + + " PID : 1488\r\n FLAGS :\r\n"; + expect(parseScQueryexPid(running)).toBe(1_488); + expect(parseScQueryexPid(running.replace(": 4 RUNNING", ": 1 STOPPED"))).toBeNull(); + expect(parseScQueryexPid(running.replace(": 1488", ": 0"))).toBeNull(); + expect(parseScQueryexPid("garbage")).toBeNull(); + }); + + test("the ARM64 terminate script compares StartTime and kills the same Process object", () => { + const script = windowsFallbackTerminateScript(4_242, 1_794_445_323_123, 1_000); + expect(script).toContain("Get-Process -Id 4242"); + expect(script).toContain("FromUnixTimeMilliseconds(1794445323123)"); + expect(script).toContain("-gt 1000) { 'reused'"); + expect(script.indexOf("$p.Kill()")).toBeGreaterThan(script.indexOf("'reused'")); + expect(script).toContain("$p.WaitForExit(1000)"); + expect(() => windowsFallbackTerminateScript(1.5, 1, 1)).toThrow(); + expect(() => windowsFallbackTerminateScript(-1, 1, 1)).toThrow(); + expect(parseFallbackTerminateOutput("terminated-exited\r\n")).toEqual({ status: "terminated", exited: true }); + expect(parseFallbackTerminateOutput("terminated")).toEqual({ status: "terminated", exited: false }); + expect(parseFallbackTerminateOutput("noise\nreused\n")).toEqual({ status: "reused" }); + expect(parseFallbackTerminateOutput("denied")).toEqual({ status: "denied" }); + expect(parseFallbackTerminateOutput("gone")).toEqual({ status: "gone" }); + expect(parseFallbackTerminateOutput("")).toEqual({ status: "unavailable" }); + expect(parseFallbackTerminateOutput("Killed")).toEqual({ status: "unavailable" }); + }); +}); + +describe("Windows process evidence: facades", () => { + test("a simulated platform reaches neither the kernel nor a spawn", async () => { + setWindowsProcessPlatformForTests("win32"); + expect(windowsProcessNative()).toBeNull(); + expect(await windowsProcessSnapshot({ timeoutMs: 5_000 })).toBeNull(); + expect(await readScheduleServicePid({ timeoutMs: 2_000 })).toBeNull(); + expect(await terminateWindowsProcessBound(20, 5_000, { timeoutMs: 1_000 })).toEqual({ status: "unavailable" }); + expect(openWindowsProcessWatch(20, 5_000)).toEqual({ status: "unavailable" }); + }); + + test("the snapshot runs one capped CIM query and overlays native creation times", async () => { + setWindowsProcessPlatformForTests("win32"); + const cim = Date.parse("2026-11-12T01:02:03.123Z"); + setWindowsProcessNativeForTests(fakeNative({ creationTime: pid => (pid === 20 ? cim : null) })); + const commands: WindowsEvidenceCommand[] = []; + const snapshot = await windowsProcessSnapshot({ + timeoutMs: 5_000, + run: async command => { commands.push(command); return SNAPSHOT_JSON; }, + }); + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ program: "powershell", timeoutMs: 5_000 }); + expect(commands[0]!.args.at(-1)).toBe(WINDOWS_PROCESS_SNAPSHOT_SCRIPT); + expect(snapshot?.map(row => [row.pid, row.startTime])).toEqual([[20, cim], [4, null]]); + expect(await windowsProcessSnapshot({ timeoutMs: 5_000, run: async () => { throw new Error("timeout"); } })).toBeNull(); + }); + + test("the Schedule PID prefers the SCM read and falls back to sc.exe queryex", async () => { + setWindowsProcessPlatformForTests("win32"); + setWindowsProcessNativeForTests(fakeNative({ scheduleServicePid: () => 1_234 })); + expect(await readScheduleServicePid({ timeoutMs: 2_000 })).toBe(1_234); + setWindowsProcessNativeForTests(null); + const commands: WindowsEvidenceCommand[] = []; + const pid = await readScheduleServicePid({ + timeoutMs: 2_000, + run: async command => { commands.push(command); return "STATE : 4 RUNNING\nPID : 77\n"; }, + }); + expect(pid).toBe(77); + expect(commands).toEqual([{ program: "sc", args: ["queryex", "Schedule"], timeoutMs: 2_000, maxBufferBytes: 64 * 1024 }]); + expect(await readScheduleServicePid({ timeoutMs: 2_000, run: async () => { throw new Error("1060"); } })).toBeNull(); + }); + + test("terminate prefers the handle primitive and otherwise runs the bounded fallback script", async () => { + setWindowsProcessPlatformForTests("win32"); + const seen: Array<[number, number]> = []; + setWindowsProcessNativeForTests(fakeNative({ terminate: (pid, start) => { seen.push([pid, start]); return { status: "reused" }; } })); + expect(await terminateWindowsProcessBound(20, 5_000, { timeoutMs: 3_000 })).toEqual({ status: "reused" }); + expect(seen).toEqual([[20, 5_000]]); + + setWindowsProcessNativeForTests(null); + const scripts: string[] = []; + const result = await terminateWindowsProcessBound(20, 5_000, { + timeoutMs: 3_000, + run: async command => { scripts.push(command.args.at(-1)!); return "terminated-exited\n"; }, + }); + expect(result).toEqual({ status: "terminated", exited: true }); + expect(scripts[0]).toContain("Get-Process -Id 20"); + expect(await terminateWindowsProcessBound(20, 5_000, { timeoutMs: 3_000, run: async () => { throw new Error("x"); } })) + .toEqual({ status: "unavailable" }); + }); + + test("a fractional deadline-derived timeout reaches the fallback as whole milliseconds", async () => { + setWindowsProcessPlatformForTests("win32"); + setWindowsProcessNativeForTests(null); + const commands: WindowsEvidenceCommand[] = []; + const run = async (command: WindowsEvidenceCommand) => { commands.push(command); return "terminated\n"; }; + expect(await terminateWindowsProcessBound(20, 5_000, { timeoutMs: 1_250.5, run })) + .toEqual({ status: "terminated", exited: false }); + expect(commands.map(command => command.timeoutMs)).toEqual([1_250]); + expect(commands[0]!.args.at(-1)).toContain("$p.WaitForExit(750)"); + // Under 1 ms floors to 0, which execFile reads as "no timeout": nothing is spawned. + expect(await terminateWindowsProcessBound(20, 5_000, { timeoutMs: 0.5, run })).toEqual({ status: "unavailable" }); + expect(commands).toHaveLength(1); + }); +}); + +const liveX64 = process.platform !== "win32" || process.arch !== "x64"; + +describe("Windows process evidence: live x64 primitive", () => { + test.skipIf(liveX64)("reads a child's creation time, refuses a mismatched identity and ends the bound one", async () => { + const child = Bun.spawn([process.execPath, "-e", "setInterval(() => {}, 1000)"], { + stdio: ["ignore", "ignore", "ignore"], windowsHide: true, + }); + let created: number | null = null; + try { + const native = windowsProcessNative(); + expect(native).not.toBeNull(); + created = native!.creationTime(child.pid); + expect(typeof created).toBe("number"); + expect(Math.abs(created! - Date.now())).toBeLessThan(60_000); + + expect(native!.terminate(child.pid, created! - 5_000)).toEqual({ status: "reused" }); + const watch = native!.openWatch(child.pid, created!); + expect(watch.status).toBe("open"); + if (watch.status !== "open") return; + try { + expect(watch.exited()).toBe(false); + expect(native!.terminate(child.pid, created!)).toEqual({ status: "terminated" }); + await child.exited; + expect(watch.exited()).toBe(true); + } finally { + watch.close(); + } + } finally { + child.kill(); + await child.exited; + } + // Exited: a zombie still held open reads as exited, a released PID fails to open + // (ERROR_INVALID_PARAMETER), and a PID already reused by another process is refused. + expect(["gone", "reused"]).toContain(windowsProcessNative()!.terminate(child.pid, created!).status); + }); + + test.skipIf(liveX64)("reads the Task Scheduler service PID from the SCM", () => { + const pid = windowsProcessNative()!.scheduleServicePid(); + expect(pid === null || (Number.isSafeInteger(pid) && pid > 0)).toBe(true); + }); +}); diff --git a/tests/windows/windows-stop-binding.test.ts b/tests/windows/windows-stop-binding.test.ts new file mode 100644 index 00000000000..387a5b53571 --- /dev/null +++ b/tests/windows/windows-stop-binding.test.ts @@ -0,0 +1,185 @@ +// Binding rule of the checked Windows stop (#7771, 030 "Binding rule"). +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; + +import { + bindWindowsStopWrappers, + windowsHomeWrapperPids, + windowsWrapperImage, + type WindowsStopBindingContext, +} from "../../src/service/windows-stop-binding"; +import { + CMD, OTHER_HOME_VBS, PATHS, SCHEDULE_PID, VBS, + entry, launcher, loop, proxy, scheduleHost, supervisedTree, +} from "../helpers/windows-checked-stop-fixtures"; + +const context = (overrides: Partial = {}): WindowsStopBindingContext => ({ + processes: supervisedTree(), + canonicalPaths: PATHS, + childPid: 42, + taskRegistered: true, + managerRegistered: true, + scheduleServicePid: SCHEDULE_PID, + selfPid: 1, + ...overrides, +}); + +describe("checked Windows stop binding", () => { + test("binds the launcher and the looping cmd.exe above the child, deepest first", () => { + const binding = bindWindowsStopWrappers(context()); + expect(binding.bound.map(wrapper => [wrapper.pid, wrapper.image, wrapper.startTime])).toEqual([ + [30, "cmd.exe", 5_100], + [20, "wscript.exe", 5_000], + ]); + expect(binding.unbound).toEqual([]); + expect(binding.unreadable).toEqual([]); + expect(binding.child).toEqual({ pid: 42, startTime: 5_200 }); + // The receipt keeps a digest; the command line itself never leaves the snapshot. + expect(binding.bound[0]!.commandLineDigest) + .toBe(createHash("sha256").update(loop().commandLine!, "utf8").digest("hex")); + expect(JSON.stringify(binding)).not.toContain("opencodex-service"); + }); + + test("the proxy child is never a candidate, whatever its image or command line", () => { + const child = entry(42, 30, "cmd.exe", 'cmd.exe /c "' + CMD + '"', 5_200); + const binding = bindWindowsStopWrappers(context({ processes: [scheduleHost, launcher(), loop(), child] })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + }); + + test("the path token under a non-wrapper image is neither bound nor reported", () => { + const editor = entry(70, 500, "notepad.exe", 'notepad.exe "' + VBS + '"', 6_000); + const binding = bindWindowsStopWrappers(context({ processes: [...supervisedTree(), editor] })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + expect(binding.unbound).toEqual([]); + }); + + test("another home's wrapper and a substring-only match are not this home's wrappers", () => { + const other = entry(80, SCHEDULE_PID, "wscript.exe", 'wscript.exe //B "' + OTHER_HOME_VBS + '"', 6_000); + const suffixed = entry(81, SCHEDULE_PID, "cmd.exe", 'cmd.exe /c "' + CMD + '.bak"', 6_000); + const prefixed = entry(82, SCHEDULE_PID, "cmd.exe", "cmd.exe /c X" + CMD, 6_000); + const binding = bindWindowsStopWrappers(context({ processes: [...supervisedTree(), other, suffixed, prefixed] })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + expect(binding.unbound).toEqual([]); + }); + + test("a stray launcher of a stopped task binds through the Schedule host, with its loop", () => { + // The task is no longer running and the proxy is gone, but the wrapper relaunches it. + const binding = bindWindowsStopWrappers(context({ + processes: [scheduleHost, launcher(), loop()], childPid: null, + })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + }); + + test("an unreadable Schedule host PID disables that clause", () => { + const binding = bindWindowsStopWrappers(context({ + processes: [scheduleHost, launcher(), loop()], childPid: null, scheduleServicePid: null, + })); + expect(binding.bound).toEqual([]); + expect(binding.unbound.map(wrapper => wrapper.pid).sort()).toEqual([20, 30]); + }); + + test("an ancestor of the identity-checked child binds on that clause alone", () => { + // The launcher's parent is a live, older shell, so neither the Schedule host clause nor + // "parent gone" applies; only 030's first lineage clause can bind it. + const shell = entry(500, 4, "explorer.exe", "explorer.exe", 2_000); + const binding = bindWindowsStopWrappers(context({ + processes: [scheduleHost, shell, launcher(20, 500), loop(), proxy()], + taskRegistered: false, + })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + expect(binding.unbound).toEqual([]); + }); + + test("a wrapper whose parent PID is unreadable never binds, registered or not", () => { + const tree = [scheduleHost, { ...launcher(), parentPid: null }, loop()]; + expect(bindWindowsStopWrappers(context({ processes: tree, childPid: null })).bound).toEqual([]); + const unregistered = bindWindowsStopWrappers(context({ + processes: tree, childPid: null, taskRegistered: false, managerRegistered: false, + })); + expect(unregistered.bound).toEqual([]); + expect(unregistered.unbound.map(wrapper => wrapper.pid).sort()).toEqual([20, 30]); + }); + + test("with the task registered, a wrapper whose parent PID is unreadable is unreadable, not unbound", () => { + const binding = bindWindowsStopWrappers(context({ + processes: [scheduleHost, { ...launcher(), parentPid: null }, loop()], childPid: null, + })); + expect(binding.unreadable).toEqual([20]); + expect(binding.unbound.map(wrapper => wrapper.pid)).not.toContain(20); + }); + + test("an orphan binds only while this home's task is registered", () => { + const orphanTree = [launcher(20, 777), loop()]; + const registered = bindWindowsStopWrappers(context({ processes: orphanTree, childPid: null })); + expect(registered.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + const unregistered = bindWindowsStopWrappers(context({ + processes: orphanTree, childPid: null, taskRegistered: false, managerRegistered: false, + })); + expect(unregistered.bound).toEqual([]); + expect(unregistered.unbound.map(wrapper => wrapper.pid).sort()).toEqual([20, 30]); + }); + + test("a parent PID created after the wrapper is a reused PID, so the parent is gone", () => { + const reusedParent = entry(777, 4, "explorer.exe", "explorer.exe", 9_000); + const binding = bindWindowsStopWrappers(context({ + processes: [reusedParent, launcher(20, 777), loop()], childPid: null, + })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([30, 20]); + }); + + test("a launcher started by hand from a live shell fails lineage, and so does its loop", () => { + const shell = entry(500, 4, "explorer.exe", "explorer.exe", 2_000); + const binding = bindWindowsStopWrappers(context({ + processes: [scheduleHost, shell, launcher(20, 500), loop()], childPid: null, + })); + expect(binding.bound).toEqual([]); + expect(binding.unbound.map(wrapper => wrapper.pid).sort()).toEqual([20, 30]); + }); + + test("a matching wrapper without a creation time is unreadable, never bound", () => { + const binding = bindWindowsStopWrappers(context({ + processes: [scheduleHost, launcher(), loop(30, 20, null), proxy()], + })); + expect(binding.unreadable).toEqual([30]); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([20]); + }); + + test("a matching process whose image name is unreadable is unreadable", () => { + const unnamed = entry(31, 20, null, 'cmd.exe /c "' + CMD + '"', 5_150); + const binding = bindWindowsStopWrappers(context({ processes: [...supervisedTree(), unnamed] })); + expect(binding.unreadable).toEqual([31]); + }); + + test("an unreadable wrapper image under a bound wrapper is unreadable", () => { + const hidden = entry(35, 20, "cmd.exe", null, 5_300); + const binding = bindWindowsStopWrappers(context({ processes: [...supervisedTree(), hidden] })); + expect(binding.unreadable).toEqual([35]); + }); + + test("an unreadable ancestor of the child counts only while a manager is registered", () => { + const tree = [entry(10, 4, "cmd.exe", null, 1_500), proxy(42, 10)]; + expect(bindWindowsStopWrappers(context({ processes: tree })).unreadable).toEqual([10]); + expect(bindWindowsStopWrappers(context({ + processes: tree, taskRegistered: false, managerRegistered: false, + })).unreadable).toEqual([]); + }); + + test("an unrelated unreadable shell is not evidence", () => { + const unrelated = entry(60, 4, "cmd.exe", null, 1_500); + expect(bindWindowsStopWrappers(context({ processes: [...supervisedTree(), unrelated] })).unreadable).toEqual([]); + }); + + test("the stopping process is never a candidate", () => { + const binding = bindWindowsStopWrappers(context({ selfPid: 30 })); + expect(binding.bound.map(wrapper => wrapper.pid)).toEqual([20]); + }); + + test("wrapper images and the final-enumeration filter", () => { + expect(windowsWrapperImage("WScript.EXE")).toBe("wscript.exe"); + expect(windowsWrapperImage("C:\\Windows\\System32\\cscript.exe")).toBe("cscript.exe"); + expect(windowsWrapperImage("bun.exe")).toBeNull(); + expect(windowsWrapperImage(null)).toBeNull(); + const editor = entry(70, 500, "notepad.exe", 'notepad.exe "' + VBS + '"', 6_000); + expect(windowsHomeWrapperPids([...supervisedTree(), editor], PATHS, 1)).toEqual([20, 30]); + }); +}); diff --git a/tests/windows/windows-stop-confirm.test.ts b/tests/windows/windows-stop-confirm.test.ts new file mode 100644 index 00000000000..7b7416ba441 --- /dev/null +++ b/tests/windows/windows-stop-confirm.test.ts @@ -0,0 +1,120 @@ +// Part B of the checked Windows stop (#7771, 030 "Part B"): the port stays free for the +// hold, the final enumeration finds no wrapper of this home, and a listener is classified. +import { describe, expect, test } from "bun:test"; + +import type { WindowsStopProcessEntry } from "../../src/service/windows-process-evidence"; +import { + confirmWindowsStopFreed, + resolveWindowsStopPort, + type WindowsListenerIdentity, + type WindowsPortSample, + type WindowsStopConfirmDeps, +} from "../../src/service/windows-stop-confirm"; +import { CMD, PATHS, entry, launcher, scheduleHost, virtualClock } from "../helpers/windows-checked-stop-fixtures"; + +const quiet = (): WindowsStopProcessEntry[] => [scheduleHost, entry(70, 4, "explorer.exe", "explorer.exe", 1_000)]; + +function confirmDeps(options: { + sample?: (elapsed: number) => WindowsPortSample; + identity?: WindowsListenerIdentity; + processes?: WindowsStopProcessEntry[] | null; + remainingMs?: number; +} = {}) { + const clock = virtualClock(10_000); + const calls: string[] = []; + const deps: WindowsStopConfirmDeps = { + clock: clock.clock, + port: 10_100, + remainingMs: options.remainingMs ?? 30_000, + canonicalPaths: PATHS, + selfPid: 1, + samplePort: () => (options.sample ?? (() => ({ state: "free" })))(clock.now - 10_000), + identifyListener: (_port, ownerPid) => { calls.push("identify:" + ownerPid); return options.identity ?? "unknown"; }, + enumerate: () => { calls.push("enumerate@" + (clock.now - 10_000)); return options.processes === undefined ? quiet() : options.processes; }, + }; + return { deps, clock, calls }; +} + +describe("checked Windows stop, part B", () => { + test("a port that stays free for the hold with no wrapper left is freed", async () => { + const { deps, clock, calls } = confirmDeps(); + const result = await confirmWindowsStopFreed(deps); + expect(result).toMatchObject({ outcome: "freed", portHeldByForeign: false, reason: null, port: 10_100 }); + // The hold is 6 s between sample starts; the final enumeration runs after it. + expect(calls).toEqual(["enumerate@6000"]); + expect(clock.now - 10_000).toBeLessThanOrEqual(result.windowMs); + }); + + test("a wrapper relaunched after bind is caught by the final enumeration even with the port free", async () => { + const { deps } = confirmDeps({ processes: [...quiet(), launcher(90, 4, 12_000)] }); + const result = await confirmWindowsStopFreed(deps); + expect(result).toMatchObject({ outcome: "survived", lateWrappers: [90] }); + }); + + test("this installation answering on the port again is survived", async () => { + const { deps, calls } = confirmDeps({ + sample: elapsed => (elapsed < 3_000 ? { state: "free" } : { state: "listening", ownerPid: 4_242 }), + identity: "ours", + }); + const result = await confirmWindowsStopFreed(deps); + expect(result).toMatchObject({ outcome: "survived", listener: { ownerPid: 4_242, identity: "ours" } }); + expect(calls).toContain("identify:4242"); + }); + + test("a foreign listener after our processes are gone is portHeldByForeign", async () => { + const { deps } = confirmDeps({ sample: () => ({ state: "listening", ownerPid: 777 }), identity: "foreign" }); + const result = await confirmWindowsStopFreed(deps); + expect(result).toMatchObject({ outcome: "port-held-by-foreign", portHeldByForeign: true }); + }); + + test("a listener whose owner has exited is not foreign", async () => { + const { deps } = confirmDeps({ sample: () => ({ state: "listening", ownerPid: 555 }), identity: "exited" }); + const result = await confirmWindowsStopFreed(deps); + expect(result).toMatchObject({ outcome: "unconfirmed", portHeldByForeign: false, listener: { identity: "exited" } }); + }); + + test("an unreadable final enumeration is not success, even with the port free", async () => { + const { deps } = confirmDeps({ processes: null }); + expect(await confirmWindowsStopFreed(deps)).toMatchObject({ outcome: "unknown", reason: "evidence-unreadable" }); + }); + + test("a final enumeration that overruns its cap is not success", async () => { + const { deps } = confirmDeps(); + deps.enumerate = () => new Promise(() => undefined); + expect((await confirmWindowsStopFreed(deps)).outcome).toBe("unknown"); + }); + + test("a process with this home's path and an unreadable image name is unknown, never freed or foreign", async () => { + const unnamed = entry(91, 4, null, 'cmd.exe /c "' + CMD + '"', 12_000); + const free = confirmDeps({ processes: [...quiet(), unnamed] }); + expect(await confirmWindowsStopFreed(free.deps)) + .toMatchObject({ outcome: "unknown", reason: "evidence-unreadable", lateWrappers: [] }); + const held = confirmDeps({ + processes: [...quiet(), unnamed], sample: () => ({ state: "listening", ownerPid: 777 }), identity: "foreign", + }); + expect(await confirmWindowsStopFreed(held.deps)) + .toMatchObject({ outcome: "unknown", portHeldByForeign: false, reason: "evidence-unreadable" }); + }); + + test("a slow port release reports failure instead of overrunning the window", async () => { + const { deps, clock } = confirmDeps({ + remainingMs: 14_500, + sample: elapsed => (elapsed < 2_000 ? { state: "listening", ownerPid: 4_242 } : { state: "free" }), + identity: "ours", + }); + const result = await confirmWindowsStopFreed(deps); + expect(result.windowMs).toBe(6_000); + expect(result.outcome).toBe("unconfirmed"); + expect(clock.now - 10_000).toBeLessThanOrEqual(6_000 + 500 + 5_000); + }); + + test("the port comes from the runtime record, then the wrapper script, then the config", () => { + expect(resolveWindowsStopPort({ runtimePort: () => 10_200, wrapperPort: () => 10_300, configuredPort: () => 10_100 })) + .toEqual({ port: 10_200, source: "runtime" }); + expect(resolveWindowsStopPort({ runtimePort: () => null, wrapperPort: () => 10_300, configuredPort: () => 10_100 })) + .toEqual({ port: 10_300, source: "wrapper" }); + expect(resolveWindowsStopPort({ + runtimePort: () => { throw new Error("unreadable"); }, wrapperPort: () => 0, configuredPort: () => 10_100, + })).toEqual({ port: 10_100, source: "config" }); + }); +});