|
| 1 | +/** |
| 2 | + * Keeping the daemon alive on Windows, via a Scheduled Task. |
| 3 | + * |
| 4 | + * Windows has a real service manager, and it is the wrong tool here: a true |
| 5 | + * service needs Administrator to register, runs in session 0, and would spawn |
| 6 | + * every coding agent outside the user's desktop session and away from their |
| 7 | + * environment. Task Scheduler registers per-user with no elevation, which is |
| 8 | + * what an install script run from an ordinary shell can actually do. |
| 9 | + * |
| 10 | + * ## How a task becomes a keepalive |
| 11 | + * |
| 12 | + * Task Scheduler has no `KeepAlive`. `RestartOnFailure` is not it either: that |
| 13 | + * fires when an action *fails*, and the self-updater ends the daemon with exit |
| 14 | + * 0, which is a success. Under that setting an update would swap the binary, |
| 15 | + * exit cleanly, and never come back. |
| 16 | + * |
| 17 | + * So the restart is built from two settings that do apply, and the combination |
| 18 | + * is the whole trick: |
| 19 | + * |
| 20 | + * - a **logon trigger with a repetition** of one minute, so Windows attempts to |
| 21 | + * start the task continuously, for ever; and |
| 22 | + * - **`MultipleInstancesPolicy: IgnoreNew`**, which does not start a new |
| 23 | + * instance while one is already running. |
| 24 | + * |
| 25 | + * Together they mean: while the daemon is up, every attempt is ignored; the |
| 26 | + * moment it exits, the next attempt starts it. That is a poll rather than a |
| 27 | + * supervisor, so the worst-case gap is the repetition interval — a minute, |
| 28 | + * against launchd's ten-second throttle. Acceptable for an update restart, and |
| 29 | + * the reason `update/scheduler.ts` only ever exits when the daemon is idle. |
| 30 | + * |
| 31 | + * `ExecutionTimeLimit: PT0S` disables the three-day default kill, which would |
| 32 | + * otherwise terminate a long-lived daemon on a schedule. |
| 33 | + */ |
| 34 | +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; |
| 35 | +import { homedir } from "node:os"; |
| 36 | +import { dirname, join } from "node:path"; |
| 37 | +import { logDir } from "../logs.js"; |
| 38 | +import { |
| 39 | + LABEL, |
| 40 | + programArguments, |
| 41 | + type CommandResult, |
| 42 | + type InstallOptions, |
| 43 | + type ServiceStatus, |
| 44 | +} from "./service-shared.js"; |
| 45 | + |
| 46 | +/** Task Scheduler's own name for the job. */ |
| 47 | +export const TASK_NAME = LABEL; |
| 48 | + |
| 49 | +/** |
| 50 | + * Where the generated task XML is kept. |
| 51 | + * |
| 52 | + * Written to a stable path because it is also the marker `supervisorInstalled()` |
| 53 | + * reads: it is the one artefact of a Windows install that exists as a file. The |
| 54 | + * same caveat applies as to the launchd plist — someone can delete the task and |
| 55 | + * leave the file — but the file is never written unless an install ran, which is |
| 56 | + * the question being asked. |
| 57 | + */ |
| 58 | +export function taskXmlPath(home = homedir()): string { |
| 59 | + return join(home, ".pew2", "service", `${TASK_NAME}.xml`); |
| 60 | +} |
| 61 | + |
| 62 | +function escapeXml(value: string): string { |
| 63 | + return value |
| 64 | + .replace(/&/g, "&") |
| 65 | + .replace(/</g, "<") |
| 66 | + .replace(/>/g, ">") |
| 67 | + .replace(/"/g, """); |
| 68 | +} |
| 69 | + |
| 70 | +/** |
| 71 | + * Quote one argument of a Windows command line. |
| 72 | + * |
| 73 | + * Task Scheduler hands `Arguments` to the process as a single string, which is |
| 74 | + * split by the same rules as any other command line, so a path containing a |
| 75 | + * space has to be quoted or it arrives as two arguments. |
| 76 | + */ |
| 77 | +function quoteArgument(value: string): string { |
| 78 | + return /[\s"]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; |
| 79 | +} |
| 80 | + |
| 81 | +export function buildTaskXml(options: InstallOptions = {}): string { |
| 82 | + const program = programArguments(options.bunPath); |
| 83 | + const logs = logDir(options.env); |
| 84 | + const port = options.port ?? Number(options.env?.PEW2_PORT ?? 8787); |
| 85 | + |
| 86 | + // A task carries no environment of its own, and unlike launchd and systemd |
| 87 | + // there is no per-variable field to set. So the daemon is started through |
| 88 | + // `cmd /c` with the variables assigned inline, which is also what lets stdout |
| 89 | + // be redirected to the log files the daemon rotates. |
| 90 | + const assignments = [`set "PEW2_PORT=${port}"`]; |
| 91 | + if (options.experimental) assignments.push(`set "PEW2_EXPERIMENTAL=1"`); |
| 92 | + if (options.env?.PEW2_HOME) assignments.push(`set "PEW2_HOME=${options.env.PEW2_HOME}"`); |
| 93 | + if (options.env?.PEW2_RELAY) assignments.push(`set "PEW2_RELAY=${options.env.PEW2_RELAY}"`); |
| 94 | + |
| 95 | + const start = program.map(quoteArgument).join(" "); |
| 96 | + const line = |
| 97 | + `${assignments.join(" && ")} && ${start} ` + |
| 98 | + `>> ${quoteArgument(join(logs, "daemon.log"))} ` + |
| 99 | + `2>> ${quoteArgument(join(logs, "daemon.error.log"))}`; |
| 100 | + |
| 101 | + // UTF-16 is what `schtasks /create /xml` expects, and the declaration has to |
| 102 | + // say so even though the file is written as UTF-8 with a BOM below. |
| 103 | + return `<?xml version="1.0" encoding="UTF-16"?> |
| 104 | +<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task"> |
| 105 | + <RegistrationInfo> |
| 106 | + <Description>pew2 daemon — remote control for desktop coding agents</Description> |
| 107 | + <URI>\\${escapeXml(TASK_NAME)}</URI> |
| 108 | + </RegistrationInfo> |
| 109 | + <Triggers> |
| 110 | + <LogonTrigger> |
| 111 | + <Enabled>true</Enabled> |
| 112 | + <!-- Retried for ever, so the daemon comes back after it exits to update. |
| 113 | + Duration must exceed Interval or Windows rejects the definition. --> |
| 114 | + <Repetition> |
| 115 | + <Interval>PT1M</Interval> |
| 116 | + <Duration>P3650D</Duration> |
| 117 | + <StopAtDurationEnd>false</StopAtDurationEnd> |
| 118 | + </Repetition> |
| 119 | + </LogonTrigger> |
| 120 | + </Triggers> |
| 121 | + <Principals> |
| 122 | + <Principal id="Author"> |
| 123 | + <LogonType>InteractiveToken</LogonType> |
| 124 | + <RunLevel>LeastPrivilege</RunLevel> |
| 125 | + </Principal> |
| 126 | + </Principals> |
| 127 | + <Settings> |
| 128 | + <!-- The keepalive: while the daemon runs, every retry above is ignored. --> |
| 129 | + <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy> |
| 130 | + <!-- A laptop on battery is the normal case for this, not an exception. --> |
| 131 | + <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries> |
| 132 | + <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries> |
| 133 | + <AllowHardTerminate>true</AllowHardTerminate> |
| 134 | + <StartWhenAvailable>true</StartWhenAvailable> |
| 135 | + <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable> |
| 136 | + <!-- Without this the task is killed after three days by default. --> |
| 137 | + <ExecutionTimeLimit>PT0S</ExecutionTimeLimit> |
| 138 | + <Enabled>true</Enabled> |
| 139 | + <Hidden>false</Hidden> |
| 140 | + <Priority>7</Priority> |
| 141 | + </Settings> |
| 142 | + <Actions Context="Author"> |
| 143 | + <Exec> |
| 144 | + <Command>cmd.exe</Command> |
| 145 | + <Arguments>/c ${escapeXml(line)}</Arguments> |
| 146 | + </Exec> |
| 147 | + </Actions> |
| 148 | +</Task> |
| 149 | +`; |
| 150 | +} |
| 151 | + |
| 152 | +export interface SchtasksDeps { |
| 153 | + runCommand: (command: string, args: string[]) => Promise<CommandResult>; |
| 154 | + home?: string; |
| 155 | + /** Injected so tests do not spend the startup poll in real time. */ |
| 156 | + sleep?: (milliseconds: number) => Promise<void>; |
| 157 | +} |
| 158 | + |
| 159 | +export async function installScheduledTask( |
| 160 | + options: InstallOptions, |
| 161 | + deps: SchtasksDeps, |
| 162 | +): Promise<ServiceStatus> { |
| 163 | + const home = options.home ?? deps.home ?? homedir(); |
| 164 | + const path = taskXmlPath(home); |
| 165 | + await mkdir(dirname(path), { recursive: true }); |
| 166 | + await mkdir(logDir(options.env), { recursive: true }); |
| 167 | + // A BOM, because schtasks reads the file as UTF-16/Unicode and rejects a |
| 168 | + // plain UTF-8 one with a bare "The task XML is malformed". |
| 169 | + await writeFile(path, `\ufeff${buildTaskXml(options)}`, "utf8"); |
| 170 | + |
| 171 | + // `/f` overwrites an existing registration, so re-running install picks up a |
| 172 | + // changed definition instead of failing on a name clash. |
| 173 | + const created = await deps.runCommand("schtasks", [ |
| 174 | + "/create", |
| 175 | + "/tn", |
| 176 | + TASK_NAME, |
| 177 | + "/xml", |
| 178 | + path, |
| 179 | + "/f", |
| 180 | + ]); |
| 181 | + if (created.code !== 0) { |
| 182 | + return { |
| 183 | + state: "installed", |
| 184 | + servicePath: path, |
| 185 | + detail: `Written, but schtasks /create failed: ${created.stdout.trim() || `exit ${created.code}`}`, |
| 186 | + }; |
| 187 | + } |
| 188 | + |
| 189 | + // A logon trigger does not fire until the next logon, so the first run is |
| 190 | + // started by hand — otherwise `pew2 setup` reports success on a daemon that |
| 191 | + // will not exist until the user signs out and back in. |
| 192 | + await deps.runCommand("schtasks", ["/run", "/tn", TASK_NAME]); |
| 193 | + |
| 194 | + const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms))); |
| 195 | + for (let i = 0; i < 15; i++) { |
| 196 | + const status = await scheduledTaskStatus(home, deps); |
| 197 | + if (status.state === "running") return status; |
| 198 | + await sleep(200); |
| 199 | + } |
| 200 | + return scheduledTaskStatus(home, deps); |
| 201 | +} |
| 202 | + |
| 203 | +export async function uninstallScheduledTask( |
| 204 | + home: string, |
| 205 | + deps: SchtasksDeps, |
| 206 | +): Promise<ServiceStatus> { |
| 207 | + const path = taskXmlPath(home); |
| 208 | + await deps.runCommand("schtasks", ["/end", "/tn", TASK_NAME]); |
| 209 | + await deps.runCommand("schtasks", ["/delete", "/tn", TASK_NAME, "/f"]); |
| 210 | + await rm(path, { force: true }); |
| 211 | + return { state: "not-installed", servicePath: path }; |
| 212 | +} |
| 213 | + |
| 214 | +export async function scheduledTaskStatus( |
| 215 | + home: string, |
| 216 | + deps: SchtasksDeps, |
| 217 | +): Promise<ServiceStatus> { |
| 218 | + const path = taskXmlPath(home); |
| 219 | + const logPath = join(logDir(), "daemon.log"); |
| 220 | + |
| 221 | + try { |
| 222 | + await readFile(path, "utf8"); |
| 223 | + } catch { |
| 224 | + return { state: "not-installed", servicePath: path, logPath }; |
| 225 | + } |
| 226 | + |
| 227 | + const queried = await deps.runCommand("schtasks", ["/query", "/tn", TASK_NAME, "/fo", "LIST"]); |
| 228 | + if (queried.code !== 0) { |
| 229 | + return { state: "installed", servicePath: path, logPath, detail: "Registered but not loaded." }; |
| 230 | + } |
| 231 | + |
| 232 | + // `Status: Running` is Task Scheduler's word for "an instance is executing". |
| 233 | + // Matched loosely because the label is localised on a non-English Windows, |
| 234 | + // where falling back to "installed" is the safe answer rather than a wrong one. |
| 235 | + const running = /^Status:\s*Running\s*$/im.test(queried.stdout); |
| 236 | + const lastResult = queried.stdout.match(/^Last Result:\s*(-?\d+)\s*$/im)?.[1]; |
| 237 | + |
| 238 | + return { |
| 239 | + state: running ? "running" : "installed", |
| 240 | + servicePath: path, |
| 241 | + logPath, |
| 242 | + lastExitCode: lastResult ? Number(lastResult) : undefined, |
| 243 | + }; |
| 244 | +} |
0 commit comments