Skip to content

Commit 12eae74

Browse files
authored
Merge pull request lidge-jun#780 from lidge-jun/fix/764-windows-service-stop
fix(service): make Windows scheduler stop actually stop the proxy
2 parents 7132828 + 0ef0e08 commit 12eae74

6 files changed

Lines changed: 176 additions & 25 deletions

File tree

src/lib/winsw.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ function runWinsw(args: string[]): string {
165165

166166
/** `install /p` prompts for the service-account password on the console — stdin must be inherited. */
167167
function runWinswInteractive(args: string[]): void {
168+
if (!process.stdin.isTTY) {
169+
throw new Error(
170+
"WinSW install requires an interactive console to prompt for the service account password. "
171+
+ "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.",
172+
);
173+
}
168174
execFileSync(winswExePath(), args, { stdio: "inherit" });
169175
}
170176

src/server/proxy-liveness.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
109109
return verified === candidate ? verified : null;
110110
};
111111

112+
const verifiedReportedPid = (reported: number | null): number | null => {
113+
if (reported === null) return null;
114+
if (!Number.isSafeInteger(reported) || reported <= 0) return null;
115+
const verified = verifyPidFn(reported);
116+
return verified === reported ? verified : null;
117+
};
118+
112119
const pid = readPidFn();
113120
let probedPort: number | null = null;
114121
if (pid) {
@@ -136,7 +143,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
136143
// (its process dead, the port reused by a pidless legacy proxy) — synthesizing it
137144
// would hand destructive callers (stopProxy → kill fallback) a reusable pid.
138145
if (identity) {
139-
return { pid: identity.pid ?? null, port: record.port, hostname: record.hostname, source: "runtime" };
146+
return { pid: verifiedReportedPid(identity.pid), port: record.port, hostname: record.hostname, source: "runtime" };
140147
}
141148
}
142149

@@ -145,7 +152,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
145152
const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
146153
if (identity) {
147154
return {
148-
pid: identity.pid ?? killablePid(pid),
155+
pid: verifiedReportedPid(identity.pid) ?? killablePid(pid),
149156
port,
150157
hostname: config.hostname,
151158
source: "config",

src/service.ts

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@
66
* restore it via the command.
77
*/
88
import { execFileSync, execSync } from "node:child_process";
9+
import { findLiveProxy } from "./server/proxy-liveness";
910
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1011
import { homedir } from "node:os";
1112
import { dirname, join, resolve } from "node:path";
12-
import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
13+
import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
1314
import { loadConfig } from "./config";
1415
import { restoreNativeCodex } from "./codex/inject";
1516
import { stripGrokConfig } from "./grok/inject";
1617
import { isWslRuntime } from "./codex/home";
1718
import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
1819
import { isProcessAlive, stopProxy } from "./lib/process-control";
1920
import { serviceApiTokenFilePath } from "./lib/service-secrets";
20-
import { findLiveProxy } from "./server/proxy-liveness";
2121
import { randomUUID } from "node:crypto";
2222
import {
2323
ELEVATION_REQUEST_TIMEOUT_MS,
@@ -1428,7 +1428,38 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
14281428
* scheduler backend first; on failure the machine is left with NO service (explicitly
14291429
* reported) — never a silent fallback to the scheduler.
14301430
*/
1431+
/** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */
1432+
export function assertWindowsNativeServiceAccountSupported(): void {
1433+
if (process.platform !== "win32") return;
1434+
const source = readWindowsPrincipalSource();
1435+
if (source?.toLowerCase() === "microsoftaccount") {
1436+
throw new Error(
1437+
"The native (WinSW) service backend cannot run under a Microsoft-account Windows login. "
1438+
+ "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.",
1439+
);
1440+
}
1441+
}
1442+
1443+
function readWindowsPrincipalSource(): string | null {
1444+
if (process.platform !== "win32") return null;
1445+
const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
1446+
if (!existsSync(ps)) return null;
1447+
try {
1448+
const out = execFileSync(ps, [
1449+
"-NoLogo",
1450+
"-NoProfile",
1451+
"-NonInteractive",
1452+
"-Command",
1453+
"(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource",
1454+
], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
1455+
return out || null;
1456+
} catch {
1457+
return null;
1458+
}
1459+
}
1460+
14311461
async function installWindowsNative(): Promise<void> {
1462+
assertWindowsNativeServiceAccountSupported();
14321463
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
14331464
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
14341465
writeServiceApiTokenFile();
@@ -1463,11 +1494,49 @@ async function installWindowsNative(): Promise<void> {
14631494
writeServiceInstallState("native");
14641495
}
14651496
function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
1466-
function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
1497+
1498+
export function isWindowsSchedulerEndBenign(error: unknown): boolean {
1499+
const detail = schtasksErrorDetail(error).toLowerCase();
1500+
return detail.includes("no running instance")
1501+
|| detail.includes("not currently running")
1502+
|| detail.includes("0x41330");
1503+
}
1504+
1505+
/**
1506+
* End the scheduler task. "Already stopped" is success; other `/end` failures are
1507+
* swallowed so callers can still run tracked-proxy + live-proxy cleanup.
1508+
*
1509+
* Do not key a restart-window wait on `/end` failure: the #764 case is an `/end`
1510+
* that *succeeds* while the wrapper survives and respawns. That verification lives
1511+
* on the stop-verification path (poll across the restart window), not here.
1512+
*/
1513+
export function stopWindows(): void {
1514+
try {
1515+
schtasks(["/end", "/tn", TASK]);
1516+
} catch (error) {
1517+
if (isWindowsSchedulerEndBenign(error)) return;
1518+
}
1519+
}
14671520
function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
14681521
function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
14691522
function uninstallWindows(): void {
1470-
try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
1523+
const probe = probeWindowsSchedulerTask(TASK);
1524+
if (probe.status === "present") {
1525+
try {
1526+
schtasks(["/delete", "/tn", TASK, "/f"]);
1527+
} catch (error) {
1528+
throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`);
1529+
}
1530+
const afterDelete = probeWindowsSchedulerTask(TASK);
1531+
if (afterDelete.status === "present") {
1532+
throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`);
1533+
}
1534+
if (afterDelete.status === "unknown") {
1535+
throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`);
1536+
}
1537+
} else if (probe.status === "unknown") {
1538+
throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`);
1539+
}
14711540
if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
14721541
if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath());
14731542
if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
@@ -1626,6 +1695,12 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
16261695

16271696
type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
16281697

1698+
function verifiedKillTarget(pid: number | null | undefined): number | null {
1699+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
1700+
const verified = verifyPidIdentity(pid);
1701+
return verified === pid ? verified : null;
1702+
}
1703+
16291704
/**
16301705
* Whether a proxy is still answering after the service manager claimed to stop it.
16311706
*
@@ -1666,17 +1741,31 @@ export async function proxyStillLiveAfterStop(deps: {
16661741
}
16671742

16681743
async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
1744+
let stopped = false;
16691745
const pid = readPid();
1670-
if (!pid) return "none";
1671-
if (!isProcessAlive(pid)) {
1746+
const trackedKillPid = verifiedKillTarget(pid);
1747+
if (trackedKillPid !== null && isProcessAlive(trackedKillPid)) {
1748+
await stopProxy(trackedKillPid);
1749+
removePid(trackedKillPid);
1750+
removeRuntimePort(trackedKillPid);
1751+
stopped = true;
1752+
} else if (pid) {
16721753
removePid(pid);
16731754
removeRuntimePort(pid);
1674-
return "stale";
16751755
}
1676-
await stopProxy(pid);
1677-
removePid(pid);
1678-
removeRuntimePort(pid);
1679-
return "stopped";
1756+
// Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
1757+
// a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
1758+
const live = await findLiveProxy({ timeoutMs: 1500 });
1759+
const liveKillPid = verifiedKillTarget(live?.pid);
1760+
if (liveKillPid !== null) {
1761+
await stopProxy(liveKillPid);
1762+
removePid(liveKillPid);
1763+
removeRuntimePort(liveKillPid);
1764+
stopped = true;
1765+
}
1766+
if (stopped) return "stopped";
1767+
if (pid) return "stale";
1768+
return "none";
16801769
}
16811770

16821771
async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
@@ -1984,11 +2073,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
19842073
ops.start();
19852074
console.log("✅ service started.");
19862075
break;
1987-
case "stop":
2076+
case "stop": {
19882077
assertServiceEnvironmentMatchesInstall();
19892078
// Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
19902079
// (and its Windows/Linux twins) even with nothing installed.
1991-
if (ops.status() !== null || isServiceInstalled()) ops.stop();
2080+
if (ops.status() !== null || isServiceInstalled()) {
2081+
ops.stop();
2082+
}
19922083
await stopTrackedProxyForServiceCommand();
19932084
{
19942085
// Verify rather than trust the stop command: a surviving wrapper respawns its child
@@ -2015,6 +2106,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
20152106
else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
20162107
}
20172108
break;
2109+
}
20182110
case "status": {
20192111
if (process.platform === "win32" && backend === "scheduler") {
20202112
console.log(await inspectWindowsSchedulerServiceStatus());
@@ -2060,3 +2152,4 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
20602152
process.exit(1);
20612153
}
20622154
}
2155+

tests/proxy-liveness.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ describe("findLiveProxy", () => {
7575
readPidFn: () => null,
7676
readRuntimeFn: () => null,
7777
configFn: () => ({ port: 10100 }),
78+
verifyPidFn: candidate => candidate,
7879
fetchFn: (async () => healthz(OURS)) as typeof fetch,
7980
});
8081

@@ -98,6 +99,7 @@ describe("findLiveProxy", () => {
9899
readPidFn: () => null,
99100
readRuntimeFn: () => ({ pid: 4242, port: 58195, hostname: "::1" }),
100101
configFn: () => ({ port: 10100 }),
102+
verifyPidFn: candidate => candidate,
101103
fetchFn: (async (url: string | URL | Request) => {
102104
urls.push(String(url));
103105
return healthz(OURS);
@@ -138,14 +140,14 @@ describe("findLiveProxy", () => {
138140
test("a runtime record whose healthz reports a different pid is rejected", async () => {
139141
const live = await findLiveProxy({
140142
readPidFn: () => 1111,
143+
verifyPidFn: () => null,
141144
readRuntimeFn: () => ({ port: 58195 }),
142145
configFn: () => ({ port: 58195 }),
143146
fetchFn: (async () => healthz({ ...OURS, pid: 9999 })) as typeof fetch,
144147
});
145148

146-
// The runtime probe fails the pid check; the config fallback probes the same port
147-
// without a pid expectation and adopts the reported live pid instead.
148-
expect(live).toEqual({ pid: 9999, port: 58195, source: "config" });
149+
// healthz-reported pids must pass identity verification before they become kill targets.
150+
expect(live).toEqual({ pid: null, port: 58195, source: "config" });
149151
});
150152

151153
test("a pidless legacy healthz never promotes an unverified cheap pid to a kill target", async () => {

tests/service.test.ts

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -644,31 +644,67 @@ describe("service lifecycle cleanup ordering", () => {
644644
expect(service).toContain('code !== "EBUSY" && code !== "EPERM" && code !== "EACCES"');
645645
});
646646

647-
test("Windows service uninstall removes generated task XML", async () => {
647+
test("Windows service uninstall verifies task deletion before removing assets", async () => {
648648
const service = await readText("src/service.ts");
649649
const uninstallWindows = service.slice(service.indexOf("function uninstallWindows()"), service.indexOf("function serviceDiagnosticsSummary()"));
650650

651+
expect(uninstallWindows).toContain("probeWindowsSchedulerTask(TASK)");
651652
expect(uninstallWindows).toContain("windowsServiceScriptPath()");
652653
expect(uninstallWindows).toContain("windowsTaskXmlPath()");
653654
expect(uninstallWindows).toContain("unlinkSync(windowsTaskXmlPath())");
655+
expect(uninstallWindows).toContain("refusing to remove service assets");
654656
});
655657

656-
test("service cleanup stops gracefully first via the shared stopper and clears the pid file", async () => {
658+
test("service cleanup falls back to findLiveProxy and clears the pid file", async () => {
657659
const service = await readText("src/service.ts");
658660

659-
expect(service).toContain('import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";');
661+
expect(service).toContain('verifyPidIdentity');
660662
expect(service).toContain("removeRuntimePort(pid);");
661663
expect(service).toContain('import { isProcessAlive, stopProxy } from "./lib/process-control";');
664+
expect(service).toContain('import { findLiveProxy } from "./server/proxy-liveness";');
662665
expect(service).toContain('type TrackedProxyCleanupResult = "none" | "stale" | "stopped";');
663666
expect(service).toContain("async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult>");
664-
expect(service).toContain('if (!pid) return "none";');
665-
expect(service).toContain("if (!isProcessAlive(pid))");
666-
expect(service).toContain('return "stale";');
667-
expect(service).toContain("await stopProxy(pid);");
667+
expect(service).toContain("await findLiveProxy({ timeoutMs: 1500 })");
668+
expect(service).toContain("await stopProxy(trackedKillPid);");
669+
expect(service).toContain("await stopProxy(liveKillPid);");
668670
expect(service).toContain("removePid(pid);");
669671
expect(service).toContain('return "stopped";');
670672
});
671673

674+
675+
test("Windows scheduler stop does not wait on schtasks /end failure", async () => {
676+
const service = await readText("src/service.ts");
677+
const stopCase = service.slice(service.indexOf('case "stop":'), service.indexOf('case "status":'));
678+
// #764 is an /end that succeeds while the wrapper respawns; waiting only when
679+
// /end errors cannot catch that path. Restart-window polling is proxyStillLiveAfterStop.
680+
expect(stopCase).not.toContain("WINDOWS_SCHEDULER_WRAPPER_RESTART_MS");
681+
expect(stopCase).not.toContain("schedulerEndOk");
682+
expect(stopCase).not.toContain("await Bun.sleep(");
683+
expect(stopCase).toContain("await proxyStillLiveAfterStop()");
684+
});
685+
686+
test("tracked proxy cleanup verifies health-reported pids before stopProxy", async () => {
687+
const service = await readText("src/service.ts");
688+
expect(service).toContain("function verifiedKillTarget(pid: number | null | undefined): number | null");
689+
expect(service).toContain("const liveKillPid = verifiedKillTarget(live?.pid);");
690+
expect(service).toContain("const trackedKillPid = verifiedKillTarget(pid);");
691+
});
692+
test("service stop refuses success while the proxy is still live", async () => {
693+
const service = await readText("src/service.ts");
694+
const stopCase = service.slice(service.indexOf('case "stop":'), service.indexOf('case "status":'));
695+
expect(stopCase).toContain("await proxyStillLiveAfterStop()");
696+
expect(stopCase).toContain("a proxy is still listening on port");
697+
expect(stopCase).toContain("Native Codex was NOT restored");
698+
expect(stopCase).toContain("process.exitCode = 1");
699+
});
700+
701+
test("native install refuses Microsoft-account logins before removing the scheduler backend", async () => {
702+
const service = await readText("src/service.ts");
703+
const installNative = service.slice(service.indexOf("async function installWindowsNative()"), service.indexOf("function startWindows()"));
704+
expect(installNative.indexOf("assertWindowsNativeServiceAccountSupported()")).toBeLessThan(installNative.indexOf("uninstallWindows()"));
705+
expect(service).toContain("Microsoft-account Windows login");
706+
});
707+
672708
test("service command cleanup logs kill failures without skipping restore/delete", async () => {
673709
const service = await readText("src/service.ts");
674710

tests/winsw.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,13 @@ describe("winsw install flow", () => {
165165
expect(calls).toEqual([["interactive", "install", "/p"], ["verify"], ["run", "start"]]);
166166
});
167167

168+
test("install /p refuses non-interactive stdin instead of hanging", () => {
169+
const winsw = readFileSync(new URL("../src/lib/winsw.ts", import.meta.url), "utf8");
170+
const fn = winsw.slice(winsw.indexOf("function runWinswInteractive"), winsw.indexOf("function scQc()"));
171+
expect(fn).toContain("process.stdin.isTTY");
172+
expect(fn).toContain("interactive console");
173+
});
174+
168175
test("repair over an existing service rewrites assets and restarts without re-prompting", async () => {
169176
const calls: string[][] = [];
170177
await installWinswService(entry, {

0 commit comments

Comments
 (0)