karajan-code 4.1.9 → 4.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -19,10 +19,7 @@
|
|
|
19
19
|
* out-of-control result list cannot blow the token budget.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
import { promisify } from "node:util";
|
|
24
|
-
|
|
25
|
-
const execFileAsync = promisify(execFile);
|
|
22
|
+
import { runGoverned } from "../utils/tool-governor.js";
|
|
26
23
|
|
|
27
24
|
const SCAN_TIMEOUT_MS = 60_000;
|
|
28
25
|
const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "MODERATE"];
|
|
@@ -42,7 +39,10 @@ export async function collectOsvFindings(projectDir, logger = null) {
|
|
|
42
39
|
}
|
|
43
40
|
let stdout;
|
|
44
41
|
try {
|
|
45
|
-
|
|
42
|
+
// KJC-TSK-0668: governed run — single-flight across kj processes and a
|
|
43
|
+
// timeout that kills the whole process tree (osv-scanner is I/O-bound,
|
|
44
|
+
// no jobs flag to cap).
|
|
45
|
+
const { stdout: out } = await runGoverned(
|
|
46
46
|
"osv-scanner",
|
|
47
47
|
["--format=json", "--recursive", projectDir || "."],
|
|
48
48
|
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }
|
|
@@ -18,10 +18,7 @@
|
|
|
18
18
|
* into the `security` dimension using the rule ID as the rule field.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
import { promisify } from "node:util";
|
|
23
|
-
|
|
24
|
-
const execFileAsync = promisify(execFile);
|
|
21
|
+
import { runGoverned, jobsCap } from "../utils/tool-governor.js";
|
|
25
22
|
|
|
26
23
|
const SCAN_TIMEOUT_MS = 120_000;
|
|
27
24
|
const SEVERITY_ORDER = ["ERROR", "WARNING", "INFO"];
|
|
@@ -44,9 +41,12 @@ export async function collectSemgrepFindings(projectDir, logger = null) {
|
|
|
44
41
|
}
|
|
45
42
|
let stdout;
|
|
46
43
|
try {
|
|
47
|
-
|
|
44
|
+
// KJC-TSK-0668: governed run — machine-wide single-flight, --jobs capped
|
|
45
|
+
// at half the CPUs (semgrep grabs 14 by default), nice +10, and a timeout
|
|
46
|
+
// that kills the whole process tree (no more orphaned semgrep-core).
|
|
47
|
+
const { stdout: out } = await runGoverned(
|
|
48
48
|
"semgrep",
|
|
49
|
-
["--config", "auto", "--json", "--quiet", `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
|
|
49
|
+
["--config", "auto", "--json", "--quiet", `--jobs=${jobsCap()}`, `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
|
|
50
50
|
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
|
|
51
51
|
);
|
|
52
52
|
stdout = out;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphaned scanner detection for `kj doctor` (KJC-TSK-0668). After the CPU
|
|
3
|
+
* storm, timed-out scans left semgrep-core processes reparented to init /
|
|
4
|
+
* systemd, silently burning cores. This check finds processes of tools kj
|
|
5
|
+
* governs whose parent is init (pid 1) or a systemd reaper, reports them,
|
|
6
|
+
* and — strategy "prompt" — offers to kill them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { STRATEGY } from "./types.js";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const GOVERNED_TOOLS = new Set(["semgrep", "semgrep-core", "osv-scanner", "lighthouse"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan the process table for governed tools whose parent is init or a
|
|
18
|
+
* systemd reaper (where the kernel sends orphans). `runPs` injectable.
|
|
19
|
+
*/
|
|
20
|
+
export async function findOrphanToolProcesses(runPs = execFileAsync) {
|
|
21
|
+
if (process.platform === "win32") return { supported: false, orphans: [] };
|
|
22
|
+
let stdout;
|
|
23
|
+
try {
|
|
24
|
+
({ stdout } = await runPs("ps", ["-eo", "pid=,ppid=,comm="]));
|
|
25
|
+
} catch {
|
|
26
|
+
return { supported: false, orphans: [] };
|
|
27
|
+
}
|
|
28
|
+
const rows = [];
|
|
29
|
+
const commByPid = new Map();
|
|
30
|
+
for (const line of String(stdout).split("\n")) {
|
|
31
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
32
|
+
if (!m) continue;
|
|
33
|
+
const row = { pid: Number(m[1]), ppid: Number(m[2]), comm: m[3].trim() };
|
|
34
|
+
rows.push(row);
|
|
35
|
+
commByPid.set(row.pid, row.comm);
|
|
36
|
+
}
|
|
37
|
+
const orphans = rows.filter((r) =>
|
|
38
|
+
GOVERNED_TOOLS.has(r.comm) && (r.ppid === 1 || commByPid.get(r.ppid) === "systemd")
|
|
39
|
+
);
|
|
40
|
+
return { supported: true, orphans };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createOrphanCheck() {
|
|
44
|
+
return {
|
|
45
|
+
name: "tool-orphans",
|
|
46
|
+
label: "Orphaned scanner processes",
|
|
47
|
+
strategy: STRATEGY.PROMPT,
|
|
48
|
+
describe: "Kill orphaned scanner processes left behind by interrupted runs",
|
|
49
|
+
async detect({ runPs } = {}) {
|
|
50
|
+
const { supported, orphans } = await findOrphanToolProcesses(runPs || execFileAsync);
|
|
51
|
+
if (!supported) return { ok: true, severity: "info", detail: "orphan detection not supported on this platform" };
|
|
52
|
+
if (orphans.length === 0) return { ok: true, severity: "info", detail: "no orphaned scanner processes" };
|
|
53
|
+
const list = orphans.map((o) => `${o.comm}(${o.pid})`).join(", ");
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
severity: "warn",
|
|
57
|
+
detail: `${orphans.length} orphaned scanner process(es) burning CPU: ${list}`,
|
|
58
|
+
fix: `kill -9 ${orphans.map((o) => o.pid).join(" ")}`,
|
|
59
|
+
extra: { orphans },
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
async remediate({ extra, kill = process.kill } = {}) {
|
|
63
|
+
let killed = 0;
|
|
64
|
+
for (const o of extra?.orphans || []) {
|
|
65
|
+
try {
|
|
66
|
+
kill(o.pid, "SIGKILL");
|
|
67
|
+
killed += 1;
|
|
68
|
+
} catch { /* already gone */ }
|
|
69
|
+
}
|
|
70
|
+
return { fixed: killed > 0, detail: `killed ${killed} orphaned scanner process(es)` };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getOrphanChecks() {
|
|
76
|
+
return [createOrphanCheck()];
|
|
77
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -35,6 +35,7 @@ import { getSkillsChecks } from "../checks/skills.js";
|
|
|
35
35
|
import { getDirSetupChecks } from "../checks/dir-setup.js";
|
|
36
36
|
import { getProjectChecks } from "../checks/project-checks.js";
|
|
37
37
|
import { getHardwareChecks } from "../checks/hardware.js";
|
|
38
|
+
import { getOrphanChecks } from "../checks/orphans.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Build the list of Check objects applicable to the current config.
|
|
@@ -72,6 +73,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
|
|
|
72
73
|
...getRtkChecks(),
|
|
73
74
|
...getSqueezrChecks(),
|
|
74
75
|
...getAiTrashChecks(),
|
|
76
|
+
...getOrphanChecks(),
|
|
75
77
|
...getQmdChecks(),
|
|
76
78
|
...getRagHooksChecks({ projectDir }),
|
|
77
79
|
...getProjectChecks({ projectDir }),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-governor — resource governance for external tools launched by kj
|
|
3
|
+
* (KJC-TSK-0668), born from a real semgrep CPU storm: single-flight across
|
|
4
|
+
* processes (pid-stamped lockfile, stale locks stolen), jobs cap (half the
|
|
5
|
+
* CPUs), nice +10 best-effort, and hard timeouts that kill the PROCESS TREE
|
|
6
|
+
* (own process group on POSIX). In-process cousin: async-lock.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_LOCKS_DIR = path.join(os.homedir(), ".karajan", "locks");
|
|
15
|
+
const NICENESS = 10;
|
|
16
|
+
|
|
17
|
+
/** Parallelism cap for scanner `--jobs` flags: half the CPUs, min 1. */
|
|
18
|
+
export function jobsCap(cpuCount = os.cpus().length) {
|
|
19
|
+
return Math.max(1, Math.floor(cpuCount / 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isPidAlive(pid) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return err.code === "EPERM"; // alive but owned by another user
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if the live holder outlasts `timeoutMs`. */
|
|
32
|
+
export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500 } = {}) {
|
|
33
|
+
fs.mkdirSync(locksDir, { recursive: true });
|
|
34
|
+
const lockPath = path.join(locksDir, `${tool}.lock`);
|
|
35
|
+
const deadline = Date.now() + timeoutMs;
|
|
36
|
+
for (;;) {
|
|
37
|
+
try {
|
|
38
|
+
// wx = atomic create-or-fail: the filesystem is the arbiter.
|
|
39
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
40
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
return { release: () => fs.rmSync(lockPath, { force: true }) };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code !== "EEXIST") throw err;
|
|
45
|
+
let holder = null;
|
|
46
|
+
try {
|
|
47
|
+
holder = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
48
|
+
} catch { /* unreadable → stale */ }
|
|
49
|
+
if (!holder || !Number.isInteger(holder.pid) || !isPidAlive(holder.pid)) {
|
|
50
|
+
fs.rmSync(lockPath, { force: true });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() >= deadline) {
|
|
54
|
+
const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}) — gave up waiting after ${timeoutMs}ms`);
|
|
55
|
+
e.code = "ELOCKTIMEOUT";
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Governed spawn: own process group (POSIX), lowered priority, timeout kills
|
|
65
|
+
* the whole tree. Rejections mirror execFile's contract (err.code /
|
|
66
|
+
* err.killed / err.stdout) so scanner collectors keep their error handling.
|
|
67
|
+
*/
|
|
68
|
+
export function governedExecFile(bin, args, { cwd, timeout = 0, maxBuffer = 16 * 1024 * 1024, niceness = NICENESS } = {}) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const posix = process.platform !== "win32";
|
|
71
|
+
const child = spawn(bin, args, { cwd, detached: posix, stdio: ["ignore", "pipe", "pipe"] });
|
|
72
|
+
let stdout = "";
|
|
73
|
+
let stderr = "";
|
|
74
|
+
let killedByGovernor = false, timer = null;
|
|
75
|
+
const killTree = () => {
|
|
76
|
+
killedByGovernor = true;
|
|
77
|
+
try {
|
|
78
|
+
if (posix) process.kill(-child.pid, "SIGKILL");
|
|
79
|
+
else child.kill("SIGKILL");
|
|
80
|
+
} catch { /* tree already gone */ }
|
|
81
|
+
};
|
|
82
|
+
if (timeout > 0) timer = setTimeout(killTree, timeout);
|
|
83
|
+
child.on("spawn", () => {
|
|
84
|
+
try {
|
|
85
|
+
os.setPriority(child.pid, niceness);
|
|
86
|
+
} catch { /* best-effort */ }
|
|
87
|
+
});
|
|
88
|
+
const bufferBlown = () => stdout.length + stderr.length > maxBuffer;
|
|
89
|
+
child.stdout.on("data", (d) => { stdout += d; if (bufferBlown()) killTree(); });
|
|
90
|
+
child.stderr.on("data", (d) => { stderr += d; if (bufferBlown()) killTree(); });
|
|
91
|
+
child.on("error", (err) => {
|
|
92
|
+
if (timer) clearTimeout(timer);
|
|
93
|
+
reject(Object.assign(err, { stdout, stderr }));
|
|
94
|
+
});
|
|
95
|
+
child.on("close", (code) => {
|
|
96
|
+
if (timer) clearTimeout(timer);
|
|
97
|
+
if (killedByGovernor) {
|
|
98
|
+
reject(Object.assign(new Error(`${bin} killed by governor after ${timeout}ms (process tree included)`), { killed: true, stdout, stderr }));
|
|
99
|
+
} else if (code === 0) {
|
|
100
|
+
resolve({ stdout, stderr });
|
|
101
|
+
} else {
|
|
102
|
+
reject(Object.assign(new Error(`${bin} exited with code ${code}`), { code, stdout, stderr }));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Lock + governed exec + guaranteed release. `opts.bin` overrides the executable. */
|
|
109
|
+
export async function runGoverned(tool, args, opts = {}) {
|
|
110
|
+
const lock = await acquireToolLock(tool, opts.lock);
|
|
111
|
+
try {
|
|
112
|
+
return await governedExecFile(opts.bin || tool, args, opts);
|
|
113
|
+
} finally {
|
|
114
|
+
lock.release();
|
|
115
|
+
}
|
|
116
|
+
}
|