diff --git a/src/store.ts b/src/store.ts index 8528eda..3031bc7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -809,17 +809,70 @@ export function serializeDraftCandidate( export function editorProcesses(): string[] { try { - if (platform() === "win32") { - const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf-8", timeout: 3000 }); - return matchEditorProcesses(result.stdout ?? "", "win32"); - } - const result = spawnSync("ps", ["-axo", "comm="], { encoding: "utf-8", timeout: 3000 }); - return matchEditorProcesses(result.stdout ?? "", "posix"); + const flavor = platform() === "win32" ? "win32" : "posix"; + const result = + flavor === "win32" + ? spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf-8", timeout: 3000 }) + : spawnSync("ps", ["-axo", "pid=,ppid=,comm="], { encoding: "utf-8", timeout: 3000 }); + const rows = parseProcessRows(result.stdout ?? "", flavor); + return matchEditorRows(rows, flavor, ownProcessChain(rows, process.pid, process.ppid)); } catch { return []; } } +/** One process of a listing: its id, its parent's id (0 when the listing has none) and its executable text. */ +export interface ProcessRow { + pid: number; + ppid: number; + line: string; +} + +/** + * Rows of `ps -axo pid=,ppid=,comm=` (posix) or `tasklist /FO CSV /NH` (win32, + * which lists no parent id). A line without ids is skipped. + */ +export function parseProcessRows(listing: string, flavor: "win32" | "posix"): ProcessRow[] { + const rows: ProcessRow[] = []; + for (const raw of listing.split("\n")) { + const line = raw.trim(); + if (flavor === "win32") { + const pid = Number(line.match(/^"[^"]*","(\d+)"/)?.[1]); + if (pid) rows.push({ pid, ppid: 0, line }); + continue; + } + const m = line.match(/^(\d+)\s+(\d+)\s+(.+)$/); + if (m) rows.push({ pid: Number(m[1]), ppid: Number(m[2]), line: m[3] }); + } + return rows; +} + +/** + * The CLI's own process and every ancestor the listing shows (#168): a Volta + * shim is an executable named `capcut` that starts node, so the CLI's own + * parent looks like the editor by name. The direct parent always counts, also + * where the listing carries no parent ids (win32). + */ +export function ownProcessChain(rows: ProcessRow[], pid: number, ppid: number): Set { + const parent = new Map(rows.map((r) => [r.pid, r.ppid])); + const chain = new Set([pid, ppid]); + let at = parent.get(ppid); + while (at && at > 1 && !chain.has(at)) { + chain.add(at); + at = parent.get(at); + } + return chain; +} + +/** The editors among a listing's rows, leaving out the CLI's own process chain. */ +export function matchEditorRows(rows: ProcessRow[], flavor: "win32" | "posix", own: Set): string[] { + const listing = rows + .filter((r) => !own.has(r.pid)) + .map((r) => r.line) + .join("\n"); + return matchEditorProcesses(listing, flavor); +} + /** * Match a raw process listing against the editor executables, one process per * line — never as a substring of the joined listing: npm rewrites its own diff --git a/test/editor-processes.test.mjs b/test/editor-processes.test.mjs index 76bbb43..7d3690e 100644 --- a/test/editor-processes.test.mjs +++ b/test/editor-processes.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { matchEditorProcesses } from "../dist/store.js"; +import { matchEditorProcesses, matchEditorRows, ownProcessChain, parseProcessRows } from "../dist/store.js"; // #99: the editor-open guard must compare process names, not search the joined // listing — npm's rewritten process title contains `capcut-cli`, so a substring @@ -43,3 +43,56 @@ describe("editorProcesses matching", () => { assert.deepEqual(matchEditorProcesses("\n \n", "win32"), []); }); }); + +// #168: under Volta the `capcut` command is a shim executable named `capcut` +// that starts node, so the CLI's own parent matched the editor by name. +describe("editorProcesses leaves out the CLI's own process chain", () => { + const shim = [ + " 1 0 /sbin/launchd", + " 400 1 -zsh", + " 500 400 /Users/me/.volta/bin/capcut", + " 501 500 node", + ]; + + it("ignores a Volta shim named capcut that is the CLI's parent (#168)", () => { + const rows = parseProcessRows(shim.join("\n"), "posix"); + assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 500)), []); + }); + + it("still detects an editor running beside the shim chain", () => { + const listing = [...shim, " 700 1 /Applications/CapCut.app/Contents/MacOS/CapCut"].join("\n"); + const rows = parseProcessRows(listing, "posix"); + assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 500)), ["CapCut"]); + }); + + it("walks the whole parent chain, not only the direct parent", () => { + const listing = [ + " 400 1 -zsh", + " 450 400 capcut", + " 460 450 npm exec capcut-cli", + " 501 460 node", + ].join("\n"); + const rows = parseProcessRows(listing, "posix"); + assert.deepEqual( + [...ownProcessChain(rows, 501, 460)].sort((a, b) => a - b), + [400, 450, 460, 501], + ); + assert.deepEqual(matchEditorRows(rows, "posix", ownProcessChain(rows, 501, 460)), []); + }); + + it("keeps a command name with spaces whole", () => { + const rows = parseProcessRows(" 812 1 CapCut Helper (Renderer)", "posix"); + assert.deepEqual(rows, [{ pid: 812, ppid: 1, line: "CapCut Helper (Renderer)" }]); + }); + + it("leaves out the direct parent on win32, where tasklist has no parent ids", () => { + const listing = [ + '"capcut.exe","900","Console","1","4,096 K"', + '"node.exe","901","Console","1","45,678 K"', + '"CapCut.exe","1044","Console","1","12,345 K"', + ].join("\r\n"); + const rows = parseProcessRows(listing, "win32"); + assert.deepEqual(matchEditorRows(rows.slice(0, 2), "win32", ownProcessChain(rows, 901, 900)), []); + assert.deepEqual(matchEditorRows(rows, "win32", ownProcessChain(rows, 901, 900)), ["CapCut.exe"]); + }); +}); diff --git a/test/sync-timelines.test.mjs b/test/sync-timelines.test.mjs index fc2ad3b..015375c 100644 --- a/test/sync-timelines.test.mjs +++ b/test/sync-timelines.test.mjs @@ -420,9 +420,13 @@ describe("sync-timelines", () => { // Fake a running editor where only this test sees it: a stand-in `ps` on // the child's PATH lists a CapCut process. A real process named CapCut // would be visible to every test running at the same time, and their - // writes would be refused as well. + // writes would be refused as well. It answers `ps -axo pid=,ppid=,comm=` + // with an editor that is not in the CLI's own process chain (#168). const fakeDir = mkdtempSync(join(tmpdir(), "fake-editor-")); - writeFileSync(join(fakeDir, "ps"), "#!/bin/sh\necho /Applications/CapCut.app/Contents/MacOS/CapCut\n"); + writeFileSync( + join(fakeDir, "ps"), + "#!/bin/sh\necho ' 4242 1 /Applications/CapCut.app/Contents/MacOS/CapCut'\n", + ); chmodSync(join(fakeDir, "ps"), 0o755); const env = { PATH: `${fakeDir}${delimiter}${process.env.PATH}` }; after(() => {