Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
const parent = new Map(rows.map((r) => [r.pid, r.ppid]));
const chain = new Set<number>([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<number>): 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
Expand Down
55 changes: 54 additions & 1 deletion test/editor-processes.test.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"]);
});
});
8 changes: 6 additions & 2 deletions test/sync-timelines.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading