History

Windows service: locale-invariant status + guard --boot off-Windows

Address review on non-English Windows: - status() derived "running" from the schtasks `Status: Running` line, but both the label and the word are localized (fr: `État : En cours d'exécution`), so it always read as not-running on a non-English install. Detect via the locale-invariant SCHED_S_TASK_RUNNING result code (267009 / 0x41301) instead, extracted into the pure, tested parseSchtasksRunning(). - parseNetstatListenerPids matched the localized `LISTENING` state word. Identify a listener by its wildcard/zero remote endpoint (0.0.0.0:0 / [::]:0) instead; TCP and addresses are not localized. - `--boot` is documented Windows-only but was silently ignored on macOS/Linux. Warn when it is passed on a non-Windows platform. Tests add fr-FR / de-DE fixtures for both parsers. Verified on the Windows guest against a real running task's schtasks output: the old Status/Running regex regresses to false under fr-FR rendering while the 267009-based check stays correct.

Rhys Sullivan committed 4 months ago

Commit dac3e0a1 parent 8bd79753 files · +83 −8Browse files

Showing the changes to apps/cli/src (3 of 3 files). Show all

Unified · Split

3 files
  1. apps/cli/src/main.ts +10 −0
  2. apps/cli/src/service.test.ts +40 −0
  3. apps/cli/src/service.ts +33 −8
apps/cli/src/main.ts+10−0
@@ -2832,6 +2832,16 @@ const installService = (port: number, commandName: string, boot = false) => · 2831 unmodified lines
2832 }2832 }
2833 2833
2834 const backend = getServiceBackend();2834 const backend = getServiceBackend();
2835
2836 // `--boot` only means something on Windows (a boot/S4U Scheduled Task). The
2837 // launchd/systemd backends silently ignore the descriptor field, so warn
2838 // rather than let a macOS/Linux caller believe it took effect.
2839 if (boot && backend.platform !== "win32") {
2840 console.warn(
2841 `Note: --boot is a Windows-only option and has no effect on ${process.platform}; installing the standard login-based service.`,
2842 );
2843 }
2844
2835 if (!backend.automated) {2845 if (!backend.automated) {
2836 // Unsupported platforms surface their manual steps via the install error.2846 // Unsupported platforms surface their manual steps via the install error.
2837 yield* backend.install({2847 yield* backend.install({
apps/cli/src/service.test.ts+40−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 generateWindowsTaskXml,9 generateWindowsTaskXml,
10 getServiceBackend,10 getServiceBackend,
11 parseNetstatListenerPids,11 parseNetstatListenerPids,
12 parseSchtasksRunning,
12} from "./service";13} from "./service";
13 14
14describe("service unit generation", () => {15describe("service unit generation", () => {
@@ -188,6 +189,45 @@ describe("service unit generation", () => { · 173 unmodified lines
188 expect(parseNetstatListenerPids(netstat, 445)).toEqual([4]);189 expect(parseNetstatListenerPids(netstat, 445)).toEqual([4]);
189 expect(parseNetstatListenerPids(netstat, 1234)).toEqual([]);190 expect(parseNetstatListenerPids(netstat, 1234)).toEqual([]);
190 });191 });
192
193 it("identifies listeners by wildcard remote, so it survives a localized state column", () => {
194 // German netstat: the state word is "ABHÖREN"/"HERGESTELLT", but addresses and
195 // "TCP" are not localized. Listener detection must not depend on the word.
196 const deNetstat = [
197 "Aktive Verbindungen",
198 " Proto Lokale Adresse Remoteadresse Status PID",
199 " TCP 127.0.0.1:4789 0.0.0.0:0 ABHÖREN 4072",
200 " TCP 127.0.0.1:4789 127.0.0.1:51000 HERGESTELLT 8000",
201 ].join("\r\n");
202 expect(parseNetstatListenerPids(deNetstat, 4789)).toEqual([4072]);
203 });
204
205 it("detects a running task via the locale-invariant result code, not the Status word", () => {
206 // English verbose LIST output for a running task.
207 const en = [
208 "TaskName: \\ExecutorDaemon",
209 "Status: Running",
210 "Last Result: 267009",
211 ].join("\r\n");
212 expect(parseSchtasksRunning(en)).toBe(true);
213
214 // French Windows: both the label and the status word are translated, but the
215 // SCHED_S_TASK_RUNNING code (267009 / 0x41301) is the same.
216 const fr = [
217 "Nom de tâche: \\ExecutorDaemon",
218 "État: En cours d'exécution",
219 "Dernier résultat: 267009",
220 ].join("\r\n");
221 expect(parseSchtasksRunning(fr)).toBe(true);
222
223 // A ready/terminated task (267014 = SCHED_S_TASK_TERMINATED) is not running.
224 const ready = ["Status: Ready", "Last Result: 267014"].join(
225 "\r\n",
226 );
227 expect(parseSchtasksRunning(ready)).toBe(false);
228 // Hex form (some builds/locales) is also recognized.
229 expect(parseSchtasksRunning("Last Result: 0x41301")).toBe(true);
230 });
191});231});
192 232
193describe("service backend dispatch", () => {233describe("service backend dispatch", () => {
apps/cli/src/service.ts+33−8
@@ -680,18 +680,42 @@ const writeUtf16File = ( · 679 unmodified lines
680 });680 });
681 681
682/**682/**
683 * Parse `netstat -ano` output for the PIDs LISTENING on `port`. Pure, so it can683 * SCHED_S_TASK_RUNNING — the Task Scheduler HRESULT a task reports as its "Last
684 * Result" while it is currently running (0x00041301 == 267009). schtasks prints
685 * this in the verbose listing as a decimal; some Windows builds/locales print the
686 * hex form. The numeric code is locale-invariant even though the surrounding
687 * labels ("Last Result:", "Status:") and the human state word ("Running") are
688 * translated on a non-English Windows.
689 */
690const SCHED_S_TASK_RUNNING = 267009;
691
692/**
693 * Decide whether `schtasks /query /v /fo LIST` reports the task as currently
694 * running, without depending on the localized "Status:" line. We look for the
695 * locale-invariant SCHED_S_TASK_RUNNING result code instead. Pure + exported for
696 * unit tests (including non-English fixtures).
697 */
698export const parseSchtasksRunning = (verboseListOutput: string): boolean =>
699 new RegExp(`\\b(?:${SCHED_S_TASK_RUNNING}|0x0*41301)\\b`, "i").test(verboseListOutput);
700
701/**
702 * Parse `netstat -ano` output for the PIDs listening on `port`. Pure, so it can
684 * be unit-tested without a live socket. Matches both IPv4 (`127.0.0.1:PORT`) and703 * be unit-tested without a live socket. Matches both IPv4 (`127.0.0.1:PORT`) and
685 * IPv6 (`[::1]:PORT`) local endpoints in the LISTENING state.704 * IPv6 (`[::1]:PORT`) local endpoints. A listener is identified by its
705 * wildcard/zero remote endpoint (`0.0.0.0:0` / `[::]:0`) rather than the state
706 * column, because that column ("LISTENING") is localized on a non-English
707 * Windows while the addresses and the `TCP` token are not.
686 */708 */
709const NETSTAT_LISTENER_REMOTES = new Set(["0.0.0.0:0", "[::]:0", "*:*"]);
710
687export const parseNetstatListenerPids = (output: string, port: number): ReadonlyArray<number> => {711export const parseNetstatListenerPids = (output: string, port: number): ReadonlyArray<number> => {
688 const pids = new Set<number>();712 const pids = new Set<number>();
689 for (const line of output.split(/\r?\n/)) {713 for (const line of output.split(/\r?\n/)) {
690 const cols = line.trim().split(/\s+/);714 const cols = line.trim().split(/\s+/);
691 // TCP <local> <remote> LISTENING <pid>715 // TCP <local> <remote(=wildcard when listening)> <state> <pid>
692 if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue;716 if (cols.length < 5 || cols[0].toUpperCase() !== "TCP") continue;
693 if (cols[3].toUpperCase() !== "LISTENING") continue;
694 if (!cols[1].endsWith(`:${port}`)) continue;717 if (!cols[1].endsWith(`:${port}`)) continue;
718 if (!NETSTAT_LISTENER_REMOTES.has(cols[2])) continue;
695 const pid = Number.parseInt(cols[4], 10);719 const pid = Number.parseInt(cols[4], 10);
696 if (Number.isInteger(pid) && pid > 0) pids.add(pid);720 if (Number.isInteger(pid) && pid > 0) pids.add(pid);
697 }721 }
@@ -844,15 +868,16 @@ const makeWindowsBackend = (): ServiceBackend => { · 146 unmodified lines
844 ],868 ],
845 };869 };
846 }870 }
847 // schtasks LIST output has a localized "Status:" line: Running/Ready/Disabled.871 // Detect "running" via the locale-invariant SCHED_S_TASK_RUNNING result
848 const state = /^\s*Status:\s*(\w+)/m.exec(result.stdout)?.[1] ?? "Unknown";872 // code, not the translated "Status: Running" line (which is localized on
849 const running = state === "Running";873 // a non-English Windows and would otherwise always read as not-running).
874 const running = parseSchtasksRunning(result.stdout);
850 return {875 return {
851 platform: "win32" as const,876 platform: "win32" as const,
852 registered: true,877 registered: true,
853 running,878 running,
854 pid: null,879 pid: null,
855 detail: running ? [] : [`Scheduled task registered; current state: ${state}.`],880 detail: running ? [] : ["Scheduled task registered but not currently running."],
856 };881 };
857 }),882 }),
858 restart: () =>883 restart: () =>