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,