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.
Unified · Split
| @@ -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({ |
| @@ -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 | ||
| 14 | describe("service unit generation", () => { | 15 | describe("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 | ||
| 193 | describe("service backend dispatch", () => { | 233 | describe("service backend dispatch", () => { |
| @@ -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 can | 683 | * 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 | */ | ||
| 690 | const 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 | */ | ||
| 698 | export 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`) and | 703 | * 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 | */ |
| 709 | const NETSTAT_LISTENER_REMOTES = new Set(["0.0.0.0:0", "[::]:0", "*:*"]); | ||
| 710 | |||
| 687 | export const parseNetstatListenerPids = (output: string, port: number): ReadonlyArray<number> => { | 711 | export 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: () => |