From 002d3c46d1f6193b88e87e5f42708f34f0b88f5d Mon Sep 17 00:00:00 2001 From: Amyssjj <130730641+Amyssjj@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:01:42 -0700 Subject: [PATCH] fix(pstack): run the statechart command on Windows On Windows the command crashed after its first TLC run, and a rule that broke read as a TLC failure. killGroup and the cleanup watcher spawned /bin/sh, which Node resolves to C:\bin\sh, so the spawn failed with ENOENT and left the detached worker and its folders behind. On Windows, killGroup runs taskkill /T /F, and the watcher is janitor.ts on the worker's runtime: when the command's pipe closes, it kills the worker's tree and removes the stage and temporary folders. The command kills the worker's tree only while the worker runs, since Windows can give a finished worker's PID to another process. POSIX keeps the /bin/sh commands. Java on Windows ends lines with CRLF. outputLines, fileLines, and linesOf split on \n only, so each line kept a \r, and the message and verdict regexes, which end in $, matched nothing. TLC's exit 12 read as "its output names no rule that broke", and marks went missing from the chart. The three splitters now drop the \r before each \n. The worker also gets TEMP and TMP, which os.tmpdir() reads on Windows, so TLC's folder sits inside the folder that the command removes. The java and mise spawns pass windowsHide, so the detached worker's java runs open no console window. cli.test.ts replays three corpus cases with their TLC output in CRLF. Co-Authored-By: Claude Opus 5.5 --- .../statechart/scripts/frontends/dot.ts | 2 +- .../skills/statechart/scripts/janitor.ts | 11 +++++ .../skills/statechart/scripts/run.ts | 16 +++---- .../skills/statechart/scripts/statechart.ts | 42 ++++++++++++------- .../statechart/scripts/test/cli.test.ts | 18 ++++++++ .../statechart/scripts/frontends/dot.ts | 2 +- pstack/skills/statechart/scripts/janitor.ts | 11 +++++ pstack/skills/statechart/scripts/run.ts | 16 +++---- .../skills/statechart/scripts/statechart.ts | 42 ++++++++++++------- .../statechart/scripts/test/cli.test.ts | 18 ++++++++ 10 files changed, 132 insertions(+), 46 deletions(-) create mode 100644 harness/pstack/claude-code/skills/statechart/scripts/janitor.ts create mode 100644 pstack/skills/statechart/scripts/janitor.ts diff --git a/harness/pstack/claude-code/skills/statechart/scripts/frontends/dot.ts b/harness/pstack/claude-code/skills/statechart/scripts/frontends/dot.ts index 003dfc89d..32a41b7fa 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/frontends/dot.ts +++ b/harness/pstack/claude-code/skills/statechart/scripts/frontends/dot.ts @@ -35,7 +35,7 @@ export const parseState = (label: string, parsed = new Map; -export const linesOf = (input: Lines) => (typeof input === "string" ? input.split("\n") : input); +export const linesOf = (input: Lines) => (typeof input === "string" ? input.split(/\r?\n/) : input); export function parseDot(input: Lines): Graph { const states = new Map(); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/janitor.ts b/harness/pstack/claude-code/skills/statechart/scripts/janitor.ts new file mode 100644 index 000000000..1e6674e10 --- /dev/null +++ b/harness/pstack/claude-code/skills/statechart/scripts/janitor.ts @@ -0,0 +1,11 @@ +import fs from "node:fs"; +import { killGroup } from "./run.ts"; + +const [pid, ...dirs] = process.argv.slice(2); + +process.stdin + .on("end", async () => { + await killGroup(Number(pid)); + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 }); + }) + .resume(); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/run.ts b/harness/pstack/claude-code/skills/statechart/scripts/run.ts index 4efd368e5..4c003d666 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/run.ts +++ b/harness/pstack/claude-code/skills/statechart/scripts/run.ts @@ -55,7 +55,7 @@ export function* fileLines(file: string) { kept = end; continue; } - yield* buf.toString("utf8", 0, cut).split("\n"); + yield* buf.toString("utf8", 0, buf[cut - 1] === 13 ? cut - 1 : cut).split(/\r?\n/); kept = buf.copy(buf, 0, cut + 1, end); } yield buf.toString("utf8", 0, kept); @@ -81,7 +81,7 @@ const runChild = (file: string, args: string[], options: { cwd: string; env: Nod execFile( file, args, - { encoding: "utf8", maxBuffer: 1 << 30, ...options }, + { encoding: "utf8", maxBuffer: 1 << 30, windowsHide: true, ...options }, (error, stdout, stderr) => resolve({ status: error ? (typeof error.code === "number" ? error.code : null) : 0, @@ -94,10 +94,10 @@ const runChild = (file: string, args: string[], options: { cwd: string; env: Nod export const killGroup = (pid: number) => new Promise((resolve) => - spawn("/bin/sh", ["-c", 'kill -s KILL -- "-$1"', "sh", String(pid)], { stdio: "ignore" }).on( - "exit", - resolve, - ), + (process.platform === "win32" + ? spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true }) + : spawn("/bin/sh", ["-c", 'kill -s KILL -- "-$1"', "sh", String(pid)], { stdio: "ignore" }) + ).on("exit", resolve), ); export const messageOf = (error: unknown) => @@ -141,7 +141,7 @@ function* outputLines(output: Output) { rest = buf; continue; } - yield* utf8.decode(buf.subarray(0, cut)).split("\n"); + yield* utf8.decode(buf.subarray(0, buf[cut - 1] === 13 ? cut - 1 : cut)).split(/\r?\n/); rest = buf.subarray(cut + 1); } yield utf8.decode(rest); @@ -169,7 +169,7 @@ const runToFiles = ( for (const f of files) fds.push(fs.openSync(f, "w")); let child: ChildProcess; try { - child = spawn(file, args, { ...options, stdio: ["ignore", ...fds] }); + child = spawn(file, args, { ...options, stdio: ["ignore", ...fds], windowsHide: true }); } finally { for (const fd of fds) fs.closeSync(fd); } diff --git a/harness/pstack/claude-code/skills/statechart/scripts/statechart.ts b/harness/pstack/claude-code/skills/statechart/scripts/statechart.ts index 4e2967e68..d2919c6e9 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/statechart.ts +++ b/harness/pstack/claude-code/skills/statechart/scripts/statechart.ts @@ -110,26 +110,40 @@ try { { detached: true, stdio: ["ignore", "inherit", "inherit"], - env: { ...process.env, TMPDIR: tmp }, + env: { ...process.env, TMPDIR: tmp, TEMP: tmp, TMP: tmp }, }, ); - janitor = spawn( - "/bin/sh", - [ - "-c", - 'while read -r line; do :; done; kill -s KILL -- "-$1"; rm -rf -- "$2" "$3"', - "sh", - String(worker.pid), - stage, - tmp, - ], - { detached: true, stdio: ["pipe", "ignore", "ignore"] }, - ); + janitor = + process.platform === "win32" + ? spawn( + process.execPath, + [ + ...runtime, + path.join(import.meta.dirname, "janitor.ts"), + String(worker.pid), + stage, + tmp, + ], + { detached: true, stdio: ["pipe", "ignore", "ignore"] }, + ) + : spawn( + "/bin/sh", + [ + "-c", + 'while read -r line; do :; done; kill -s KILL -- "-$1"; rm -rf -- "$2" "$3"', + "sh", + String(worker.pid), + stage, + tmp, + ], + { detached: true, stdio: ["pipe", "ignore", "ignore"] }, + ); const exited = new Promise((resolve) => worker.on("exit", (code) => resolve(code ?? 2)), ); await Promise.race([stopped, exited]); - await killGroup(worker.pid as number); + if (process.platform !== "win32" || worker.exitCode === null) + await killGroup(worker.pid as number); const verdict = await exited; if (stop) { console.error(`${stop} stopped the check, so the command wrote nothing.`); diff --git a/harness/pstack/claude-code/skills/statechart/scripts/test/cli.test.ts b/harness/pstack/claude-code/skills/statechart/scripts/test/cli.test.ts index 97b71c30c..02e02e497 100644 --- a/harness/pstack/claude-code/skills/statechart/scripts/test/cli.test.ts +++ b/harness/pstack/claude-code/skills/statechart/scripts/test/cli.test.ts @@ -217,6 +217,24 @@ test("the order of TLC's dump and its state IDs do not change the chart or the r } }); +test("TLC's output with CRLF line ends, as Java prints it on Windows, does not change the chart or the report", () => { + for (const name of ["DieHard", "MediaPlayer", "MediaPlayerProps"]) { + const dir = copyCase(name, (d) => { + for (const f of fs.readdirSync(d)) + if (/\.(out|dot)$/.test(f)) + fs.writeFileSync(path.join(d, f), read(path.join(d, f)).replace(/\r?\n/g, "\r\n")); + }); + const r = run(dir); + assert.equal(r.stderr, "", name); + assert.equal(r.report, read(path.join(corpus, name, "report.md")), name); + assert.equal( + read(path.join(r.out, "chart.mmd")), + read(path.join(corpus, name, "chart.mmd")), + name, + ); + } +}); + function startCommand( dir: string, parent: string, diff --git a/pstack/skills/statechart/scripts/frontends/dot.ts b/pstack/skills/statechart/scripts/frontends/dot.ts index 003dfc89d..32a41b7fa 100644 --- a/pstack/skills/statechart/scripts/frontends/dot.ts +++ b/pstack/skills/statechart/scripts/frontends/dot.ts @@ -35,7 +35,7 @@ export const parseState = (label: string, parsed = new Map; -export const linesOf = (input: Lines) => (typeof input === "string" ? input.split("\n") : input); +export const linesOf = (input: Lines) => (typeof input === "string" ? input.split(/\r?\n/) : input); export function parseDot(input: Lines): Graph { const states = new Map(); diff --git a/pstack/skills/statechart/scripts/janitor.ts b/pstack/skills/statechart/scripts/janitor.ts new file mode 100644 index 000000000..1e6674e10 --- /dev/null +++ b/pstack/skills/statechart/scripts/janitor.ts @@ -0,0 +1,11 @@ +import fs from "node:fs"; +import { killGroup } from "./run.ts"; + +const [pid, ...dirs] = process.argv.slice(2); + +process.stdin + .on("end", async () => { + await killGroup(Number(pid)); + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10 }); + }) + .resume(); diff --git a/pstack/skills/statechart/scripts/run.ts b/pstack/skills/statechart/scripts/run.ts index 4efd368e5..4c003d666 100644 --- a/pstack/skills/statechart/scripts/run.ts +++ b/pstack/skills/statechart/scripts/run.ts @@ -55,7 +55,7 @@ export function* fileLines(file: string) { kept = end; continue; } - yield* buf.toString("utf8", 0, cut).split("\n"); + yield* buf.toString("utf8", 0, buf[cut - 1] === 13 ? cut - 1 : cut).split(/\r?\n/); kept = buf.copy(buf, 0, cut + 1, end); } yield buf.toString("utf8", 0, kept); @@ -81,7 +81,7 @@ const runChild = (file: string, args: string[], options: { cwd: string; env: Nod execFile( file, args, - { encoding: "utf8", maxBuffer: 1 << 30, ...options }, + { encoding: "utf8", maxBuffer: 1 << 30, windowsHide: true, ...options }, (error, stdout, stderr) => resolve({ status: error ? (typeof error.code === "number" ? error.code : null) : 0, @@ -94,10 +94,10 @@ const runChild = (file: string, args: string[], options: { cwd: string; env: Nod export const killGroup = (pid: number) => new Promise((resolve) => - spawn("/bin/sh", ["-c", 'kill -s KILL -- "-$1"', "sh", String(pid)], { stdio: "ignore" }).on( - "exit", - resolve, - ), + (process.platform === "win32" + ? spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true }) + : spawn("/bin/sh", ["-c", 'kill -s KILL -- "-$1"', "sh", String(pid)], { stdio: "ignore" }) + ).on("exit", resolve), ); export const messageOf = (error: unknown) => @@ -141,7 +141,7 @@ function* outputLines(output: Output) { rest = buf; continue; } - yield* utf8.decode(buf.subarray(0, cut)).split("\n"); + yield* utf8.decode(buf.subarray(0, buf[cut - 1] === 13 ? cut - 1 : cut)).split(/\r?\n/); rest = buf.subarray(cut + 1); } yield utf8.decode(rest); @@ -169,7 +169,7 @@ const runToFiles = ( for (const f of files) fds.push(fs.openSync(f, "w")); let child: ChildProcess; try { - child = spawn(file, args, { ...options, stdio: ["ignore", ...fds] }); + child = spawn(file, args, { ...options, stdio: ["ignore", ...fds], windowsHide: true }); } finally { for (const fd of fds) fs.closeSync(fd); } diff --git a/pstack/skills/statechart/scripts/statechart.ts b/pstack/skills/statechart/scripts/statechart.ts index 4e2967e68..d2919c6e9 100644 --- a/pstack/skills/statechart/scripts/statechart.ts +++ b/pstack/skills/statechart/scripts/statechart.ts @@ -110,26 +110,40 @@ try { { detached: true, stdio: ["ignore", "inherit", "inherit"], - env: { ...process.env, TMPDIR: tmp }, + env: { ...process.env, TMPDIR: tmp, TEMP: tmp, TMP: tmp }, }, ); - janitor = spawn( - "/bin/sh", - [ - "-c", - 'while read -r line; do :; done; kill -s KILL -- "-$1"; rm -rf -- "$2" "$3"', - "sh", - String(worker.pid), - stage, - tmp, - ], - { detached: true, stdio: ["pipe", "ignore", "ignore"] }, - ); + janitor = + process.platform === "win32" + ? spawn( + process.execPath, + [ + ...runtime, + path.join(import.meta.dirname, "janitor.ts"), + String(worker.pid), + stage, + tmp, + ], + { detached: true, stdio: ["pipe", "ignore", "ignore"] }, + ) + : spawn( + "/bin/sh", + [ + "-c", + 'while read -r line; do :; done; kill -s KILL -- "-$1"; rm -rf -- "$2" "$3"', + "sh", + String(worker.pid), + stage, + tmp, + ], + { detached: true, stdio: ["pipe", "ignore", "ignore"] }, + ); const exited = new Promise((resolve) => worker.on("exit", (code) => resolve(code ?? 2)), ); await Promise.race([stopped, exited]); - await killGroup(worker.pid as number); + if (process.platform !== "win32" || worker.exitCode === null) + await killGroup(worker.pid as number); const verdict = await exited; if (stop) { console.error(`${stop} stopped the check, so the command wrote nothing.`); diff --git a/pstack/skills/statechart/scripts/test/cli.test.ts b/pstack/skills/statechart/scripts/test/cli.test.ts index 97b71c30c..02e02e497 100644 --- a/pstack/skills/statechart/scripts/test/cli.test.ts +++ b/pstack/skills/statechart/scripts/test/cli.test.ts @@ -217,6 +217,24 @@ test("the order of TLC's dump and its state IDs do not change the chart or the r } }); +test("TLC's output with CRLF line ends, as Java prints it on Windows, does not change the chart or the report", () => { + for (const name of ["DieHard", "MediaPlayer", "MediaPlayerProps"]) { + const dir = copyCase(name, (d) => { + for (const f of fs.readdirSync(d)) + if (/\.(out|dot)$/.test(f)) + fs.writeFileSync(path.join(d, f), read(path.join(d, f)).replace(/\r?\n/g, "\r\n")); + }); + const r = run(dir); + assert.equal(r.stderr, "", name); + assert.equal(r.report, read(path.join(corpus, name, "report.md")), name); + assert.equal( + read(path.join(r.out, "chart.mmd")), + read(path.join(corpus, name, "chart.mmd")), + name, + ); + } +}); + function startCommand( dir: string, parent: string,