From 36a482de4afd584e12d8418fb15282d204fea281 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 10:49:44 +0100 Subject: [PATCH 01/11] Supervise the atom runtime from the npm dispatcher The dispatcher ran the runtime (the native binary or java) with spawnSync, so a caller's timeout killed the dispatcher and left the runtime running, adopted by init with its heap (cdxgen/cdxgen#4419). The dispatcher now: - forwards SIGINT/SIGTERM/SIGHUP/SIGQUIT to the runtime and waits for it; - enforces ATOM_TIMEOUT itself, SIGTERM then SIGKILL after ATOM_KILL_GRACE_MS (default 10 s), and exits with status 124; - stops the runtime when the process named by ATOM_PARENT_PID goes away, and passes its own pid to the runtime the same way; - reports a runtime killed by a signal as 128 + signal number; - hands leading -XX:/-Xmx runtime options to java on the jar provider, so callers can bound the heap the same way for both providers; - keeps a caller-set PHP_PARSER_BIN instead of overwriting it. --- README.md | 3 + docs/docs/cli.md | 3 + wrapper/nodejs/README.md | 3 + wrapper/nodejs/packages/atom/README.md | 3 + wrapper/nodejs/packages/atom/index.js | 102 ++++++------ wrapper/nodejs/packages/atom/package.json | 1 + wrapper/nodejs/packages/atom/supervise.js | 172 +++++++++++++++++++++ wrapper/nodejs/test/supervise.test.js | 180 ++++++++++++++++++++++ 8 files changed, 410 insertions(+), 57 deletions(-) create mode 100644 wrapper/nodejs/packages/atom/supervise.js create mode 100644 wrapper/nodejs/test/supervise.test.js diff --git a/README.md b/README.md index 6aa6fc3b..53b6f702 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,9 @@ atom -o app.atom -l java --export-atom --export-dir --with-data-dep | **ATOM_SCALASEM_SLICES_FILE** | Slices file name. Defaults to `semantics.slices.json`. | | **ATOM_JVM_ARGS** | Overrides the JVM arguments, including heap memory values, constructed by the atom Node.js wrapper. | | **ATOM_JAVA_HOME** | Java 21 or above to be used by atom. | +| **ATOM_TIMEOUT** | Maximum run time in milliseconds, enforced by the atom Node.js wrapper: atom is stopped with SIGTERM, then SIGKILL, and the wrapper exits with status 124. | +| **ATOM_KILL_GRACE_MS** | How long the wrapper waits after SIGTERM before it kills atom with SIGKILL. Default: `10000`. | +| **ATOM_PARENT_PID** | Supervising process id; cdxgen sets its own. The wrapper stops atom when it exits, and passes its own id on, so atom exits if the wrapper is killed. | | **PHP_CMD** | Overrides the PHP command used by the PHP frontend. | | **PHP_PARSER_BIN** | Overrides the php-parse command used by the PHP frontend. | | **SCALA_CMD** | Overrides the scala command. | diff --git a/docs/docs/cli.md b/docs/docs/cli.md index ef621187..57262f3e 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -88,6 +88,9 @@ Run a graph algorithm over the atom and write the result as JSON | **ATOM_SCALASEM_SLICES_FILE** | Slices file name. Defaults to `semantics.slices.json`. | | **ATOM_JVM_ARGS** | Overrides the JVM arguments, including heap memory values, constructed by the atom Node.js wrapper. | | **ATOM_JAVA_HOME** | Java 21 or above to be used by atom. | +| **ATOM_TIMEOUT** | Maximum run time in milliseconds, enforced by the atom Node.js wrapper: atom is stopped with SIGTERM, then SIGKILL, and the wrapper exits with status 124. | +| **ATOM_KILL_GRACE_MS** | How long the wrapper waits after SIGTERM before it kills atom with SIGKILL. Default: `10000`. | +| **ATOM_PARENT_PID** | Supervising process id; cdxgen sets its own. The wrapper stops atom when it exits, and passes its own id on, so atom exits if the wrapper is killed. | | **PHP_CMD** | Overrides the PHP command used by the PHP frontend. | | **PHP_PARSER_BIN** | Overrides the php-parse command used by the PHP frontend. | | **SCALA_CMD** | Overrides the scala command. | diff --git a/wrapper/nodejs/README.md b/wrapper/nodejs/README.md index b0e73ed8..25af1973 100644 --- a/wrapper/nodejs/README.md +++ b/wrapper/nodejs/README.md @@ -133,6 +133,9 @@ atom reachables --reuse-atom -o app.atom -s reachables.json -l java . | **ATOM_SCALASEM_SLICES_FILE** | Slices file name. Defaults to `semantics.slices.json`. | | **ATOM_JVM_ARGS** | Overrides the JVM arguments, including heap memory values, constructed by the atom Node.js wrapper. | | **ATOM_JAVA_HOME** | Java 21 or above to be used by atom. | +| **ATOM_TIMEOUT** | Maximum run time in milliseconds, enforced by the atom Node.js wrapper: atom is stopped with SIGTERM, then SIGKILL, and the wrapper exits with status 124. | +| **ATOM_KILL_GRACE_MS** | How long the wrapper waits after SIGTERM before it kills atom with SIGKILL. Default: `10000`. | +| **ATOM_PARENT_PID** | Supervising process id; cdxgen sets its own. The wrapper stops atom when it exits, and passes its own id on, so atom exits if the wrapper is killed. | | **PHP_CMD** | Overrides the PHP command used by the PHP frontend. | | **PHP_PARSER_BIN** | Overrides the php-parse command used by the PHP frontend. | | **SCALA_CMD** | Overrides the scala command. | diff --git a/wrapper/nodejs/packages/atom/README.md b/wrapper/nodejs/packages/atom/README.md index b0e73ed8..25af1973 100644 --- a/wrapper/nodejs/packages/atom/README.md +++ b/wrapper/nodejs/packages/atom/README.md @@ -133,6 +133,9 @@ atom reachables --reuse-atom -o app.atom -s reachables.json -l java . | **ATOM_SCALASEM_SLICES_FILE** | Slices file name. Defaults to `semantics.slices.json`. | | **ATOM_JVM_ARGS** | Overrides the JVM arguments, including heap memory values, constructed by the atom Node.js wrapper. | | **ATOM_JAVA_HOME** | Java 21 or above to be used by atom. | +| **ATOM_TIMEOUT** | Maximum run time in milliseconds, enforced by the atom Node.js wrapper: atom is stopped with SIGTERM, then SIGKILL, and the wrapper exits with status 124. | +| **ATOM_KILL_GRACE_MS** | How long the wrapper waits after SIGTERM before it kills atom with SIGKILL. Default: `10000`. | +| **ATOM_PARENT_PID** | Supervising process id; cdxgen sets its own. The wrapper stops atom when it exits, and passes its own id on, so atom exits if the wrapper is killed. | | **PHP_CMD** | Overrides the PHP command used by the PHP frontend. | | **PHP_PARSER_BIN** | Overrides the php-parse command used by the PHP frontend. | | **SCALA_CMD** | Overrides the scala command. | diff --git a/wrapper/nodejs/packages/atom/index.js b/wrapper/nodejs/packages/atom/index.js index bd1aabea..81e0149e 100755 --- a/wrapper/nodejs/packages/atom/index.js +++ b/wrapper/nodejs/packages/atom/index.js @@ -2,10 +2,13 @@ import { platform as _platform } from "node:os"; import { dirname, join, delimiter } from "node:path"; -import { readFileSync, realpathSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { locateAtomBinary, describeAtomSearch } from "./resolve.js"; +import { splitRuntimeOptions, superviseRuntime } from "./supervise.js"; + +export { TIMEOUT_EXIT_CODE } from "./supervise.js"; const isWin = _platform() === "win32"; const dirName = dirname(fileURLToPath(import.meta.url)); @@ -73,67 +76,52 @@ export const executeAtom = (atomArgs) => { } const cwd = process.env.ATOM_CWD || process.cwd(); - const timeout = process.env.ATOM_TIMEOUT ? parseInt(process.env.ATOM_TIMEOUT, 10) : undefined; + // A caller-supplied PHP_PARSER_BIN wins: the default below does not exist for the native + // packages, which do not ship php-parse. + const env = { + ...process.env, + ATOM_HOME, + PHP_PARSER_BIN: process.env.PHP_PARSER_BIN || PHP_PARSER_BIN + }; if (provider.kind === "native") { - const env = { - ...process.env, - ATOM_HOME, - PHP_PARSER_BIN - }; - const result = spawnSync(provider.binPath, atomArgs, { - encoding: "utf-8", - env, - cwd, - stdio: "inherit", - timeout - }); - process.exit(result.status !== null ? result.status : 1); - } else { - if (!detectJava()) { - const atomJavaHome = process.env.ATOM_JAVA_HOME || process.env.JAVA_HOME; - if (atomJavaHome) { - process.env.PATH = - join(atomJavaHome, "bin") + delimiter + process.env.PATH + delimiter; - } else { - console.warn( - "A Java JDK is not installed or can't be found. Please install JDK version 21 or higher before running atom." - ); - process.exit(1); - } - } - - let JAVACMD = "java"; - if (process.env.JAVA_HOME) { - JAVACMD = join(process.env.JAVA_HOME, "bin", "java" + (isWin ? ".exe" : "")); + superviseRuntime(provider.binPath, atomArgs, env, cwd); + return; + } + if (!detectJava()) { + const atomJavaHome = process.env.ATOM_JAVA_HOME || process.env.JAVA_HOME; + if (atomJavaHome) { + process.env.PATH = + join(atomJavaHome, "bin") + delimiter + process.env.PATH + delimiter; + env.PATH = process.env.PATH; + } else { + console.warn( + "A Java JDK is not installed or can't be found. Please install JDK version 21 or higher before running atom." + ); + process.exit(1); } + } - const atomLibs = [APP_CLASSPATH]; - let args = JAVA_OPTS.trim() - .split(" ") - .concat([ - "-cp", - atomLibs.join(delimiter), - `-Dlog4j2.configurationFile=${LOG4J_CONFIG}`, - APP_MAIN_CLASS, - ...atomArgs - ]); - - const env = { - ...process.env, - ATOM_HOME, - PHP_PARSER_BIN - }; - - const result = spawnSync(JAVACMD, args, { - encoding: "utf-8", - env, - cwd, - stdio: "inherit", - timeout - }); - process.exit(result.status !== null ? result.status : 1); + let JAVACMD = "java"; + if (process.env.JAVA_HOME) { + JAVACMD = join(process.env.JAVA_HOME, "bin", "java" + (isWin ? ".exe" : "")); } + + // Runtime options given in front of the atom arguments belong to the JVM. They come after + // JAVA_OPTS so that an explicit heap size overrides the default RAM percentages. + const { runtimeOptions, programArgs } = splitRuntimeOptions(atomArgs); + const atomLibs = [APP_CLASSPATH]; + const args = JAVA_OPTS.trim() + .split(" ") + .filter(Boolean) + .concat(runtimeOptions, [ + "-cp", + atomLibs.join(delimiter), + `-Dlog4j2.configurationFile=${LOG4J_CONFIG}`, + APP_MAIN_CLASS, + ...programArgs + ]); + superviseRuntime(JAVACMD, args, env, cwd); }; if (process.argv[1]) { diff --git a/wrapper/nodejs/packages/atom/package.json b/wrapper/nodejs/packages/atom/package.json index 1739b27e..866cb4ac 100644 --- a/wrapper/nodejs/packages/atom/package.json +++ b/wrapper/nodejs/packages/atom/package.json @@ -31,6 +31,7 @@ "files": [ "index.js", "resolve.js", + "supervise.js", "README.md", "LICENSE" ], diff --git a/wrapper/nodejs/packages/atom/supervise.js b/wrapper/nodejs/packages/atom/supervise.js new file mode 100644 index 00000000..4f257b82 --- /dev/null +++ b/wrapper/nodejs/packages/atom/supervise.js @@ -0,0 +1,172 @@ +import { constants as osConstants } from "node:os"; +import { spawn } from "node:child_process"; + +const isWin = process.platform === "win32"; + +// Exit status when ATOM_TIMEOUT stops atom. It is the status coreutils `timeout` uses, so a +// caller can tell "ran out of time" apart from "the analysis failed". +export const TIMEOUT_EXIT_CODE = 124; + +// How often the dispatcher checks that the process named by ATOM_PARENT_PID is still there. +const PARENT_POLL_MS = 1000; +// Signals the dispatcher relays to the runtime instead of dying from them itself. +const FORWARDED_SIGNALS = isWin + ? ["SIGINT", "SIGTERM", "SIGBREAK"] + : ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]; +// Runtime options a native image takes in front of its own arguments (cdxgen passes +// -XX:MaxHeapSize this way). The jar provider has to hand them to java instead. +const RUNTIME_OPTION_PREFIXES = ["-XX:", "-Xmx", "-Xms", "-Xmn", "-Xss"]; + +export const parsePositiveInt = (value) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +}; + +/** + * Split the leading runtime options (-XX:..., -Xmx...) off the atom arguments. + * + * @param {string[]} atomArgs Arguments given to the dispatcher + * @returns {{runtimeOptions: string[], programArgs: string[]}} + */ +export function splitRuntimeOptions(atomArgs) { + let i = 0; + while ( + i < atomArgs.length && + RUNTIME_OPTION_PREFIXES.some((prefix) => String(atomArgs[i]).startsWith(prefix)) + ) { + i++; + } + return { runtimeOptions: atomArgs.slice(0, i), programArgs: atomArgs.slice(i) }; +} + +/** + * True while `pid` names a live process. EPERM means it exists but belongs to someone else. + */ +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === "EPERM"; + } +} + +const defaultProbe = { isProcessAlive, ppid: () => process.ppid, isWin }; + +/** + * Watch the supervisor this dispatcher must not outlive, named by ATOM_PARENT_PID. + * + * cdxgen sets ATOM_PARENT_PID to its own pid. If cdxgen is killed outright while atom runs, the + * dispatcher would otherwise keep waiting on the runtime, and the runtime would keep running for + * nobody. + * + * @param {Object} [env] Environment to read ATOM_PARENT_PID from + * @param {{isProcessAlive: (pid: number) => boolean, ppid: () => number, isWin: boolean}} [probe] + * Process probes, replaceable in tests + * @returns {{pid: number, isGone: () => boolean}|undefined} undefined when no supervisor is named + */ +export function supervisorWatch(env = process.env, probe = defaultProbe) { + const pid = parsePositiveInt(env.ATOM_PARENT_PID); + if (!pid) { + return undefined; + } + const initialParent = probe.ppid(); + return { + pid, + // Gone when the named process has exited, or when our own parent has. On POSIX an orphan is + // adopted, so its parent pid changes, which also catches a supervisor that died but is still + // a zombie nobody has reaped. Windows does not reparent, so there the parent is probed too. + isGone: () => + !probe.isProcessAlive(pid) || + (probe.isWin + ? initialParent !== pid && !probe.isProcessAlive(initialParent) + : probe.ppid() !== initialParent) + }; +} + +/** + * Run the atom runtime (the native binary, or java) as a supervised child, then exit with its + * status. The dispatcher only relays, so it must never leave the runtime behind: + * + * - signals sent to the dispatcher are forwarded, and it keeps waiting until the runtime exits; + * - ATOM_TIMEOUT (milliseconds) stops the runtime (SIGTERM, then SIGKILL after + * ATOM_KILL_GRACE_MS, default 10 s) and exits with TIMEOUT_EXIT_CODE; + * - when the supervisor named by ATOM_PARENT_PID goes away, the runtime is stopped the same way; + * - the runtime receives ATOM_PARENT_PID set to the dispatcher's pid, so it exits on its own when + * the dispatcher is killed outright with a signal that cannot be forwarded (SIGKILL). + * + * @param {string} command Runtime executable + * @param {string[]} args Runtime arguments + * @param {Object} env Runtime environment + * @param {string} cwd Working directory + */ +export function superviseRuntime(command, args, env, cwd) { + const timeoutMs = parsePositiveInt(process.env.ATOM_TIMEOUT); + const killGraceMs = parsePositiveInt(process.env.ATOM_KILL_GRACE_MS) || 10000; + const supervisor = supervisorWatch(); + if (supervisor?.isGone()) { + console.error(`atom: supervising process ${supervisor.pid} is gone; not starting.`); + process.exit(1); + } + const child = spawn(command, args, { + env: { ...env, ATOM_PARENT_PID: String(process.pid) }, + cwd, + stdio: "inherit", + windowsHide: true + }); + let stopReason; + let killTimer; + const timers = []; + const stop = (reason) => { + if (stopReason) { + return; + } + stopReason = reason; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs); + }; + const forwarders = FORWARDED_SIGNALS.map((signal) => { + const forward = () => child.kill(signal); + process.on(signal, forward); + return [signal, forward]; + }); + if (timeoutMs) { + timers.push(setTimeout(() => stop("timeout"), timeoutMs)); + } + if (supervisor) { + timers.push( + setInterval(() => { + if (supervisor.isGone()) { + stop("supervisor"); + } + }, PARENT_POLL_MS) + ); + } + const cleanup = () => { + timers.forEach((t) => clearTimeout(t)); + if (killTimer) { + clearTimeout(killTimer); + } + forwarders.forEach(([signal, forward]) => process.off(signal, forward)); + }; + child.on("error", (err) => { + cleanup(); + console.error(`atom: unable to start ${command}: ${err.message}`); + process.exit(1); + }); + child.on("exit", (code, signal) => { + cleanup(); + if (stopReason === "timeout") { + console.error(`atom: stopped after exceeding ATOM_TIMEOUT (${timeoutMs} ms).`); + process.exit(TIMEOUT_EXIT_CODE); + } + if (stopReason === "supervisor") { + console.error(`atom: stopped because supervising process ${supervisor.pid} is gone.`); + } + if (code !== null) { + process.exit(code); + } + // The runtime died from a signal: report it the way a shell does. + process.exit(128 + (osConstants.signals[signal] || 0)); + }); +} diff --git a/wrapper/nodejs/test/supervise.test.js b/wrapper/nodejs/test/supervise.test.js new file mode 100644 index 00000000..5ccb6db7 --- /dev/null +++ b/wrapper/nodejs/test/supervise.test.js @@ -0,0 +1,180 @@ +import test from "node:test"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + TIMEOUT_EXIT_CODE, + splitRuntimeOptions, + supervisorWatch +} from "../packages/atom/supervise.js"; + +const SUPERVISE_SRC = fileURLToPath(new URL("../packages/atom/supervise.js", import.meta.url)); +const isWin = process.platform === "win32"; + +test("splitRuntimeOptions lifts only the leading runtime options", () => { + assert.deepEqual(splitRuntimeOptions(["-XX:MaxHeapSize=1024", "-Xss4m", "reachables", "-l", "python"]), { + runtimeOptions: ["-XX:MaxHeapSize=1024", "-Xss4m"], + programArgs: ["reachables", "-l", "python"] + }); + assert.deepEqual(splitRuntimeOptions(["usages", "-Xmx2g"]), { + runtimeOptions: [], + programArgs: ["usages", "-Xmx2g"] + }); + assert.deepEqual(splitRuntimeOptions([]), { runtimeOptions: [], programArgs: [] }); +}); + +test("supervisorWatch is off unless ATOM_PARENT_PID names a process", () => { + const probe = { isProcessAlive: () => true, ppid: () => 10, isWin: false }; + assert.equal(supervisorWatch({}, probe), undefined); + assert.equal(supervisorWatch({ ATOM_PARENT_PID: "" }, probe), undefined); + assert.equal(supervisorWatch({ ATOM_PARENT_PID: "abc" }, probe), undefined); + assert.equal(supervisorWatch({ ATOM_PARENT_PID: "0" }, probe), undefined); +}); + +test("supervisorWatch detects a dead or abandoning supervisor", () => { + let ppid = 10; + const alive = new Set([10, 20]); + const posix = { isProcessAlive: (pid) => alive.has(pid), ppid: () => ppid, isWin: false }; + const watch = supervisorWatch({ ATOM_PARENT_PID: "10" }, posix); + assert.equal(watch.pid, 10); + assert.equal(watch.isGone(), false); + // Reparented while the supervisor is still a zombie (kill(pid, 0) still succeeds). + ppid = 1; + assert.equal(watch.isGone(), true); + + ppid = 10; + alive.delete(10); + assert.equal(supervisorWatch({ ATOM_PARENT_PID: "10" }, posix).isGone(), true); + + // Windows does not reparent: an intermediate parent (cmd.exe under shell: true) is probed. + const winAlive = new Set([10, 30]); + const win = { isProcessAlive: (pid) => winAlive.has(pid), ppid: () => 30, isWin: true }; + const winWatch = supervisorWatch({ ATOM_PARENT_PID: "10" }, win); + assert.equal(winWatch.isGone(), false); + winAlive.delete(30); + assert.equal(winWatch.isGone(), true); +}); + +/** + * Supervise a stand-in runtime (a node process that writes its pid and environment to a file, + * then waits) through the real superviseRuntime, in a separate dispatcher process. + */ +function startDispatcher(env, runtimeScript) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "atom-supervise-")); + const info = path.join(dir, "runtime.json"); + const runtime = path.join(dir, "runtime.mjs"); + fs.writeFileSync( + runtime, + `import fs from "node:fs"; +fs.writeFileSync(${JSON.stringify(info)}, JSON.stringify({ pid: process.pid, parentPid: process.env.ATOM_PARENT_PID })); +${runtimeScript}` + ); + const dispatcher = path.join(dir, "dispatcher.mjs"); + fs.writeFileSync( + dispatcher, + `import { superviseRuntime } from ${JSON.stringify(SUPERVISE_SRC)}; +superviseRuntime(process.execPath, [${JSON.stringify(runtime)}], process.env, process.cwd());` + ); + const child = spawn(process.execPath, [dispatcher], { + env: { ...process.env, ...env }, + stdio: ["ignore", "ignore", "pipe"] + }); + let stderr = ""; + child.stderr.on("data", (d) => (stderr += d)); + const exited = new Promise((resolve) => + child.on("exit", (code, signal) => resolve({ code, signal, stderr })) + ); + const runtimeInfo = async () => { + for (let i = 0; i < 200; i++) { + if (fs.existsSync(info)) { + try { + return JSON.parse(fs.readFileSync(info, "utf8")); + } catch { + // still being written + } + } + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error("the stand-in runtime never started"); + }; + return { child, exited, runtimeInfo, dir }; +} + +const isAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e.code === "EPERM"; + } +}; + +const waitForDeath = async (pid) => { + for (let i = 0; i < 200 && isAlive(pid); i++) { + await new Promise((r) => setTimeout(r, 25)); + } + return !isAlive(pid); +}; + +const WAIT_FOREVER = "setInterval(() => {}, 1000);"; + +test("the runtime exit status is passed through", async () => { + const { exited, runtimeInfo } = startDispatcher({}, "process.exit(3);"); + const info = await runtimeInfo(); + assert.equal(info.parentPid !== undefined, true); + assert.equal((await exited).code, 3); +}); + +test("the runtime is told the dispatcher is its supervisor", async () => { + const { child, exited, runtimeInfo } = startDispatcher({ ATOM_PARENT_PID: "" }, "process.exit(0);"); + assert.equal((await runtimeInfo()).parentPid, String(child.pid)); + assert.equal((await exited).code, 0); +}); + +test("ATOM_TIMEOUT stops the runtime and exits with the timeout status", async () => { + const { exited, runtimeInfo } = startDispatcher( + { ATOM_TIMEOUT: "300", ATOM_KILL_GRACE_MS: "2000" }, + WAIT_FOREVER + ); + const { pid } = await runtimeInfo(); + const result = await exited; + assert.equal(result.code, TIMEOUT_EXIT_CODE); + assert.match(result.stderr, /ATOM_TIMEOUT/); + assert.equal(await waitForDeath(pid), true); +}); + +test("a runtime that ignores SIGTERM is killed after the grace period", { skip: isWin }, async () => { + const { exited, runtimeInfo } = startDispatcher( + { ATOM_TIMEOUT: "300", ATOM_KILL_GRACE_MS: "300" }, + `process.on("SIGTERM", () => {}); ${WAIT_FOREVER}` + ); + const { pid } = await runtimeInfo(); + assert.equal((await exited).code, TIMEOUT_EXIT_CODE); + assert.equal(await waitForDeath(pid), true); +}); + +test("the runtime is stopped when the supervisor named by ATOM_PARENT_PID dies", async () => { + const supervisor = spawn(process.execPath, ["-e", WAIT_FOREVER], { stdio: "ignore" }); + const { exited, runtimeInfo } = startDispatcher( + { ATOM_PARENT_PID: String(supervisor.pid) }, + WAIT_FOREVER + ); + const { pid } = await runtimeInfo(); + supervisor.kill("SIGKILL"); + const result = await exited; + assert.match(result.stderr, /supervising process/); + assert.equal(await waitForDeath(pid), true); +}); + +test("SIGTERM to the dispatcher is forwarded and waited for", { skip: isWin }, async () => { + const { child, exited, runtimeInfo } = startDispatcher({}, WAIT_FOREVER); + const { pid } = await runtimeInfo(); + child.kill("SIGTERM"); + const result = await exited; + // The stand-in runtime dies from the forwarded SIGTERM; the dispatcher reports that as 128 + 15. + assert.equal(result.code, 143); + assert.equal(isAlive(pid), false); +}); From 511c5e324bf62a84f73a7cd3b19b2635e84959b9 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 10:49:44 +0100 Subject: [PATCH 02/11] Exit when the supervising process is gone, and stop helpers on exit When ATOM_PARENT_PID names a supervisor, a daemon thread checks once a second and atom exits once that process has exited or atom has been re-parented. This covers a dispatcher killed with SIGKILL, which cannot be forwarded. A shutdown hook stops the helper processes atom started (astgen, php-parse, atom-tools), so SIGTERM, a timeout or the watchdog does not leave those behind. --- src/main/scala/io/appthreat/atom/Atom.scala | 31 +++--- .../appthreat/atom/ProcessSupervision.scala | 98 +++++++++++++++++++ .../atom/ProcessSupervisionTests.scala | 67 +++++++++++++ 3 files changed, 181 insertions(+), 15 deletions(-) create mode 100644 src/main/scala/io/appthreat/atom/ProcessSupervision.scala create mode 100644 src/test/scala/io/appthreat/atom/ProcessSupervisionTests.scala diff --git a/src/main/scala/io/appthreat/atom/Atom.scala b/src/main/scala/io/appthreat/atom/Atom.scala index 90cde2bc..f0bd32aa 100644 --- a/src/main/scala/io/appthreat/atom/Atom.scala +++ b/src/main/scala/io/appthreat/atom/Atom.scala @@ -836,21 +836,22 @@ object Atom: help("help").text("display this help message") def main(args: Array[String]): Unit = - run(args) match - case Right(_) => - case Left(errMsg) => - if errMsg == null then - println("Unexpected error") - else if errMsg.nonEmpty && errMsg.contains( - "storage metadata does not contain version number" - ) - then - println( - "Existing app.atom appears to be corrupted. Please remove and re-run this command." - ) - else - println(s"Failure: $errMsg") - System.exit(1) + ProcessSupervision.install() + run(args) match + case Right(_) => + case Left(errMsg) => + if errMsg == null then + println("Unexpected error") + else if errMsg.nonEmpty && errMsg.contains( + "storage metadata does not contain version number" + ) + then + println( + "Existing app.atom appears to be corrupted. Please remove and re-run this command." + ) + else + println(s"Failure: $errMsg") + System.exit(1) private[atom] def run(args: Array[String]): Either[String, String] = val parserArgs = args.toList diff --git a/src/main/scala/io/appthreat/atom/ProcessSupervision.scala b/src/main/scala/io/appthreat/atom/ProcessSupervision.scala new file mode 100644 index 00000000..80b3d939 --- /dev/null +++ b/src/main/scala/io/appthreat/atom/ProcessSupervision.scala @@ -0,0 +1,98 @@ +package io.appthreat.atom + +import java.util.concurrent.TimeUnit +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* +import scala.util.Try + +/** Keeps atom from outliving whoever launched it, and its helper processes from outliving atom. + * + * atom normally runs under the npm dispatcher (or directly under cdxgen), which waits for it and + * relays its exit status. When that launcher is killed outright, nothing reaps atom: it is adopted + * by init and keeps analysing for nobody, holding a heap sized for the whole machine. + * + * - `ATOM_PARENT_PID` names the supervising process; the dispatcher sets it to itself. A daemon + * thread checks once a second, and atom exits once that process is gone or atom has been + * re-parented. + * - A shutdown hook stops the helper processes atom started (astgen, php-parse, atom-tools, + * ...), so that a SIGTERM, a timeout or the watchdog does not leave those behind in turn. + */ +object ProcessSupervision: + + /** The status of a process stopped by SIGTERM, which is what the supervisor would have sent. */ + val SupervisorGoneExitCode = 143 + + private val PollMillis = 1000L + // How long helpers get to exit after SIGTERM before they are killed. + private val HelperGraceMillis = 2000L + // If exiting itself hangs (a shutdown hook stuck on an exhausted heap), halt after this long. + private val HaltGraceMillis = 15000L + + /** Whether the supervisor named by `ATOM_PARENT_PID` is gone. Kept apart from the thread so the + * decision can be tested. + * + * @param pid + * the supervisor's pid + * @param supervisor + * its handle, taken at startup. A handle remembers the process start time, so a recycled pid + * does not read as the supervisor still being alive. + * @param initialParent + * atom's parent at startup + * @param currentParent + * reads atom's parent now. An orphan is adopted (POSIX) or loses its parent (Windows), so a + * change means the launcher has exited even when it was not the supervisor itself. + */ + final class SupervisorWatch( + val pid: Long, + supervisor: Option[ProcessHandle], + initialParent: Option[Long], + currentParent: () => Option[Long] + ): + def isGone: Boolean = !supervisor.exists(_.isAlive) || currentParent() != initialParent + + /** The watch configured by `ATOM_PARENT_PID`, if any. */ + def supervisorWatch(env: Map[String, String]): Option[SupervisorWatch] = + env.get("ATOM_PARENT_PID").flatMap(_.trim.toLongOption).filter(_ > 0).map { pid => + val currentParent = () => ProcessHandle.current().parent().toScala.map(_.pid()) + SupervisorWatch(pid, ProcessHandle.of(pid).toScala, currentParent(), currentParent) + } + + /** Install the shutdown hook and, when `ATOM_PARENT_PID` is set, the supervisor watchdog. */ + def install(env: Map[String, String] = sys.env): Unit = + Runtime.getRuntime.addShutdownHook(Thread(() => stopHelpers(), "atom-stop-helpers")) + supervisorWatch(env).foreach(startWatchdog) + + private def startWatchdog(watch: SupervisorWatch): Unit = + startDaemon("atom-supervisor-watch")(watchSupervisor(watch)) + + private def watchSupervisor(watch: SupervisorWatch): Unit = + while !watch.isGone do Thread.sleep(PollMillis) + System.err.println(s"atom: supervising process ${watch.pid} is gone; stopping.") + startDaemon("atom-halt")(haltAfterGrace()) + System.exit(SupervisorGoneExitCode) + + private def haltAfterGrace(): Unit = + Thread.sleep(HaltGraceMillis) + Runtime.getRuntime.halt(SupervisorGoneExitCode) + + private def startDaemon(name: String)(body: => Unit): Unit = + val thread = Thread(() => body, name) + thread.setDaemon(true) + thread.start() + + /** Stop every process atom started, children and their descendants alike: SIGTERM first, then + * SIGKILL for any still running after a short grace period. + */ + private def stopHelpers(): Unit = + stopProcesses(ProcessHandle.current().descendants().iterator().asScala.toList) + + private[atom] def stopProcesses(helpers: List[ProcessHandle]): Unit = + helpers.foreach(_.destroy()) + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(HelperGraceMillis) + helpers.foreach { helper => + val remaining = deadline - System.nanoTime() + if remaining > 0 then + Try(helper.onExit().get(remaining, TimeUnit.NANOSECONDS)) + } + helpers.filter(_.isAlive).foreach(_.destroyForcibly()) +end ProcessSupervision diff --git a/src/test/scala/io/appthreat/atom/ProcessSupervisionTests.scala b/src/test/scala/io/appthreat/atom/ProcessSupervisionTests.scala new file mode 100644 index 00000000..ecedbecf --- /dev/null +++ b/src/test/scala/io/appthreat/atom/ProcessSupervisionTests.scala @@ -0,0 +1,67 @@ +package io.appthreat.atom + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.util.concurrent.TimeUnit +import scala.util.Properties + +/** atom must not outlive the process that supervises it, nor leave its helper processes behind. */ +class ProcessSupervisionTests extends AnyFunSuite with Matchers: + + private def startSleeper(): Process = + if Properties.isWin then + ProcessBuilder("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 60").start() + else ProcessBuilder("sleep", "60").start() + + test("the watchdog is off unless ATOM_PARENT_PID names a process"): + ProcessSupervision.supervisorWatch(Map.empty) shouldBe None + ProcessSupervision.supervisorWatch(Map("ATOM_PARENT_PID" -> "")) shouldBe None + ProcessSupervision.supervisorWatch(Map("ATOM_PARENT_PID" -> "abc")) shouldBe None + ProcessSupervision.supervisorWatch(Map("ATOM_PARENT_PID" -> "0")) shouldBe None + ProcessSupervision.supervisorWatch(Map("ATOM_PARENT_PID" -> "-5")) shouldBe None + + test("a live supervisor that is still atom's parent is not gone"): + val parent = ProcessHandle.current().parent().map(_.pid()).orElse(-1L) + val watch = ProcessSupervision.supervisorWatch(Map("ATOM_PARENT_PID" -> parent.toString)) + watch.map(_.isGone) shouldBe Some(false) + + test("a supervisor that exited is gone, even though atom's parent is unchanged"): + val sleeper = startSleeper() + try + val watch = ProcessSupervision.SupervisorWatch( + sleeper.pid(), + Some(sleeper.toHandle), + Some(1L), + () => Some(1L) + ) + watch.isGone shouldBe false + sleeper.destroyForcibly().waitFor(10, TimeUnit.SECONDS) + watch.isGone shouldBe true + finally sleeper.destroyForcibly() + + test("being re-parented means the launcher is gone"): + var parent = Option(42L) + val watch = ProcessSupervision.SupervisorWatch( + ProcessHandle.current().pid(), + Some(ProcessHandle.current()), + Some(42L), + () => parent + ) + watch.isGone shouldBe false + parent = Some(1L) + watch.isGone shouldBe true + parent = None + watch.isGone shouldBe true + + test("a supervisor that was never there is gone"): + ProcessSupervision.SupervisorWatch(12345L, None, Some(1L), () => Some(1L)).isGone shouldBe true + + test("helper processes are stopped"): + val helpers = List(startSleeper(), startSleeper()) + try + ProcessSupervision.stopProcesses(helpers.map(_.toHandle)) + helpers.foreach(_.waitFor(10, TimeUnit.SECONDS)) + helpers.map(_.isAlive) shouldBe List(false, false) + finally helpers.foreach(_.destroyForcibly()) +end ProcessSupervisionTests From 1ce8f903a29410dc35f5a798e76d91a65f9d5513 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 10:49:44 +0100 Subject: [PATCH 03/11] Remove stale reachables chunks before writing a new set A rerun into the same path with fewer flows left the earlier run's higher numbered _N.json chunks in place, and consumers that read chunks until the first gap, or glob them, picked up stale flows. --- .../atom/slicing/ReachableSlicing.scala | 16 +++++++++ .../slicing/ReachableChunkFilesTests.scala | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala diff --git a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala index 1fd7c448..a137bf83 100644 --- a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala +++ b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala @@ -214,6 +214,9 @@ object ReachableSlicing: // Deduplicate on the canonical form (see deduplicateFlows) so each finding is emitted once. val canonicalFlows = deduplicateFlows(flowIterator.toVector, sinkTagPattern) + // Chunks of an earlier run into the same path would otherwise survive next to fewer new ones, + // and consumers that read `_N.json` until the first gap would pick up stale flows. + removeChunkFiles(outputBasePath) val chunkedIterator = canonicalFlows.grouped(chunkSize).zipWithIndex var hasFlows = false @@ -229,6 +232,19 @@ object ReachableSlicing: File(s"$outputBasePath.json").writeText("[]") end calculateReachableSliceAndPersist + /** Delete the numbered chunks (`_1.json`, `_2.json`, ...) written for this base path. + */ + private[slicing] def removeChunkFiles(outputBasePath: String): Unit = + val base = File(outputBasePath) + val prefix = s"${base.name}_" + Option(base.parent).filter(_.isDirectory).foreach { dir => + dir.list(f => + f.isRegularFile && f.name.startsWith(prefix) && f.name.endsWith(".json") && + f.name.stripPrefix(prefix).stripSuffix(".json").forall(_.isDigit) && + f.name.length > prefix.length + ".json".length + ).foreach(_.delete(swallowIOExceptions = true)) + } + /** Force every node's in/out edges (and their endpoints) to materialise, single-threaded. See the * call site for why this precedes the parallel query engine. */ diff --git a/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala new file mode 100644 index 00000000..346b142b --- /dev/null +++ b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala @@ -0,0 +1,36 @@ +package io.appthreat.atom.slicing + +import better.files.File +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** A reachables run must not leave the numbered chunks of an earlier, larger run behind. */ +class ReachableChunkFilesTests extends AnyFunSuite with Matchers: + + test("only the numbered chunks of the base path are removed"): + File.usingTemporaryDirectory("atom-chunks") { dir => + val names = Seq( + "python-reachables.slices.json", + "python-reachables.slices_1.json", + "python-reachables.slices_12.json", + "python-reachables.slices_.json", + "python-reachables.slices_x.json", + "python-reachables.slices_1.json.bak", + "js-reachables.slices_1.json" + ) + names.foreach(n => (dir / n).writeText("[]")) + ReachableSlicing.removeChunkFiles((dir / "python-reachables.slices").pathAsString) + dir.list.map(_.name).toSet shouldBe Set( + "python-reachables.slices.json", + "python-reachables.slices_.json", + "python-reachables.slices_x.json", + "python-reachables.slices_1.json.bak", + "js-reachables.slices_1.json" + ) + } + + test("a base path in a missing directory is a no-op"): + noException should be thrownBy ReachableSlicing.removeChunkFiles( + "/nonexistent-dir-4419/a.slices" + ) +end ReachableChunkFilesTests From 89682b663eb8f64308be4d23a6ffef7eede2f7d7 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 10:59:57 +0100 Subject: [PATCH 04/11] Format with prettier --- wrapper/nodejs/packages/atom/index.js | 30 ++++--- wrapper/nodejs/packages/atom/supervise.js | 21 +++-- wrapper/nodejs/test/supervise.test.js | 96 ++++++++++++++++------- 3 files changed, 104 insertions(+), 43 deletions(-) diff --git a/wrapper/nodejs/packages/atom/index.js b/wrapper/nodejs/packages/atom/index.js index 81e0149e..dcfe7fc1 100755 --- a/wrapper/nodejs/packages/atom/index.js +++ b/wrapper/nodejs/packages/atom/index.js @@ -12,7 +12,9 @@ export { TIMEOUT_EXIT_CODE } from "./supervise.js"; const isWin = _platform() === "win32"; const dirName = dirname(fileURLToPath(import.meta.url)); -const selfPJson = JSON.parse(readFileSync(join(dirName, "package.json"), "utf8")); +const selfPJson = JSON.parse( + readFileSync(join(dirName, "package.json"), "utf8") +); export const ATOM_VERSION = selfPJson.version; export const JVM_ARGS = "-XX:MinRAMPercentage=30 -XX:MaxRAMPercentage=90"; @@ -53,21 +55,27 @@ function detectJava() { export const executeAtom = (atomArgs) => { if (!provider) { - console.error("Error: The '@appthreat/atom' package was not installed correctly or is unsupported on this platform."); - console.error("Please verify your installation and make sure optional dependencies are not blocked."); + console.error( + "Error: The '@appthreat/atom' package was not installed correctly or is unsupported on this platform." + ); + console.error( + "Please verify your installation and make sure optional dependencies are not blocked." + ); // Dump the full resolution search so installation issues are diagnosable from // CI logs without needing to re-run with ATOM_DEBUG. try { const diag = describeAtomSearch(); console.error( `\n[atom] resolution diagnostics:\n` + - ` dispatcher dir: ${diag.selfDir}\n` + - ` platform=${diag.platform} arch=${diag.arch} libc=${diag.libc}\n` + - ` preferred package: ${diag.preferredPkg}\n` + - ` paths checked (${diag.attempts.length}):` + ` dispatcher dir: ${diag.selfDir}\n` + + ` platform=${diag.platform} arch=${diag.arch} libc=${diag.libc}\n` + + ` preferred package: ${diag.preferredPkg}\n` + + ` paths checked (${diag.attempts.length}):` ); for (const a of diag.attempts) { - console.error(` [${a.exists ? "found" : "missing"}] (${a.pkg}, ${a.kind}) ${a.path}`); + console.error( + ` [${a.exists ? "found" : "missing"}] (${a.pkg}, ${a.kind}) ${a.path}` + ); } } catch (e) { console.error(`[atom] failed to produce diagnostics: ${e?.message || e}`); @@ -104,7 +112,11 @@ export const executeAtom = (atomArgs) => { let JAVACMD = "java"; if (process.env.JAVA_HOME) { - JAVACMD = join(process.env.JAVA_HOME, "bin", "java" + (isWin ? ".exe" : "")); + JAVACMD = join( + process.env.JAVA_HOME, + "bin", + "java" + (isWin ? ".exe" : "") + ); } // Runtime options given in front of the atom arguments belong to the JVM. They come after diff --git a/wrapper/nodejs/packages/atom/supervise.js b/wrapper/nodejs/packages/atom/supervise.js index 4f257b82..5552c2f0 100644 --- a/wrapper/nodejs/packages/atom/supervise.js +++ b/wrapper/nodejs/packages/atom/supervise.js @@ -32,11 +32,16 @@ export function splitRuntimeOptions(atomArgs) { let i = 0; while ( i < atomArgs.length && - RUNTIME_OPTION_PREFIXES.some((prefix) => String(atomArgs[i]).startsWith(prefix)) + RUNTIME_OPTION_PREFIXES.some((prefix) => + String(atomArgs[i]).startsWith(prefix) + ) ) { i++; } - return { runtimeOptions: atomArgs.slice(0, i), programArgs: atomArgs.slice(i) }; + return { + runtimeOptions: atomArgs.slice(0, i), + programArgs: atomArgs.slice(i) + }; } /** @@ -105,7 +110,9 @@ export function superviseRuntime(command, args, env, cwd) { const killGraceMs = parsePositiveInt(process.env.ATOM_KILL_GRACE_MS) || 10000; const supervisor = supervisorWatch(); if (supervisor?.isGone()) { - console.error(`atom: supervising process ${supervisor.pid} is gone; not starting.`); + console.error( + `atom: supervising process ${supervisor.pid} is gone; not starting.` + ); process.exit(1); } const child = spawn(command, args, { @@ -157,11 +164,15 @@ export function superviseRuntime(command, args, env, cwd) { child.on("exit", (code, signal) => { cleanup(); if (stopReason === "timeout") { - console.error(`atom: stopped after exceeding ATOM_TIMEOUT (${timeoutMs} ms).`); + console.error( + `atom: stopped after exceeding ATOM_TIMEOUT (${timeoutMs} ms).` + ); process.exit(TIMEOUT_EXIT_CODE); } if (stopReason === "supervisor") { - console.error(`atom: stopped because supervising process ${supervisor.pid} is gone.`); + console.error( + `atom: stopped because supervising process ${supervisor.pid} is gone.` + ); } if (code !== null) { process.exit(code); diff --git a/wrapper/nodejs/test/supervise.test.js b/wrapper/nodejs/test/supervise.test.js index 5ccb6db7..477e9533 100644 --- a/wrapper/nodejs/test/supervise.test.js +++ b/wrapper/nodejs/test/supervise.test.js @@ -11,19 +11,33 @@ import { supervisorWatch } from "../packages/atom/supervise.js"; -const SUPERVISE_SRC = fileURLToPath(new URL("../packages/atom/supervise.js", import.meta.url)); +const SUPERVISE_SRC = fileURLToPath( + new URL("../packages/atom/supervise.js", import.meta.url) +); const isWin = process.platform === "win32"; test("splitRuntimeOptions lifts only the leading runtime options", () => { - assert.deepEqual(splitRuntimeOptions(["-XX:MaxHeapSize=1024", "-Xss4m", "reachables", "-l", "python"]), { - runtimeOptions: ["-XX:MaxHeapSize=1024", "-Xss4m"], - programArgs: ["reachables", "-l", "python"] - }); + assert.deepEqual( + splitRuntimeOptions([ + "-XX:MaxHeapSize=1024", + "-Xss4m", + "reachables", + "-l", + "python" + ]), + { + runtimeOptions: ["-XX:MaxHeapSize=1024", "-Xss4m"], + programArgs: ["reachables", "-l", "python"] + } + ); assert.deepEqual(splitRuntimeOptions(["usages", "-Xmx2g"]), { runtimeOptions: [], programArgs: ["usages", "-Xmx2g"] }); - assert.deepEqual(splitRuntimeOptions([]), { runtimeOptions: [], programArgs: [] }); + assert.deepEqual(splitRuntimeOptions([]), { + runtimeOptions: [], + programArgs: [] + }); }); test("supervisorWatch is off unless ATOM_PARENT_PID names a process", () => { @@ -37,7 +51,11 @@ test("supervisorWatch is off unless ATOM_PARENT_PID names a process", () => { test("supervisorWatch detects a dead or abandoning supervisor", () => { let ppid = 10; const alive = new Set([10, 20]); - const posix = { isProcessAlive: (pid) => alive.has(pid), ppid: () => ppid, isWin: false }; + const posix = { + isProcessAlive: (pid) => alive.has(pid), + ppid: () => ppid, + isWin: false + }; const watch = supervisorWatch({ ATOM_PARENT_PID: "10" }, posix); assert.equal(watch.pid, 10); assert.equal(watch.isGone(), false); @@ -47,11 +65,18 @@ test("supervisorWatch detects a dead or abandoning supervisor", () => { ppid = 10; alive.delete(10); - assert.equal(supervisorWatch({ ATOM_PARENT_PID: "10" }, posix).isGone(), true); + assert.equal( + supervisorWatch({ ATOM_PARENT_PID: "10" }, posix).isGone(), + true + ); // Windows does not reparent: an intermediate parent (cmd.exe under shell: true) is probed. const winAlive = new Set([10, 30]); - const win = { isProcessAlive: (pid) => winAlive.has(pid), ppid: () => 30, isWin: true }; + const win = { + isProcessAlive: (pid) => winAlive.has(pid), + ppid: () => 30, + isWin: true + }; const winWatch = supervisorWatch({ ATOM_PARENT_PID: "10" }, win); assert.equal(winWatch.isGone(), false); winAlive.delete(30); @@ -129,7 +154,10 @@ test("the runtime exit status is passed through", async () => { }); test("the runtime is told the dispatcher is its supervisor", async () => { - const { child, exited, runtimeInfo } = startDispatcher({ ATOM_PARENT_PID: "" }, "process.exit(0);"); + const { child, exited, runtimeInfo } = startDispatcher( + { ATOM_PARENT_PID: "" }, + "process.exit(0);" + ); assert.equal((await runtimeInfo()).parentPid, String(child.pid)); assert.equal((await exited).code, 0); }); @@ -146,18 +174,24 @@ test("ATOM_TIMEOUT stops the runtime and exits with the timeout status", async ( assert.equal(await waitForDeath(pid), true); }); -test("a runtime that ignores SIGTERM is killed after the grace period", { skip: isWin }, async () => { - const { exited, runtimeInfo } = startDispatcher( - { ATOM_TIMEOUT: "300", ATOM_KILL_GRACE_MS: "300" }, - `process.on("SIGTERM", () => {}); ${WAIT_FOREVER}` - ); - const { pid } = await runtimeInfo(); - assert.equal((await exited).code, TIMEOUT_EXIT_CODE); - assert.equal(await waitForDeath(pid), true); -}); +test( + "a runtime that ignores SIGTERM is killed after the grace period", + { skip: isWin }, + async () => { + const { exited, runtimeInfo } = startDispatcher( + { ATOM_TIMEOUT: "300", ATOM_KILL_GRACE_MS: "300" }, + `process.on("SIGTERM", () => {}); ${WAIT_FOREVER}` + ); + const { pid } = await runtimeInfo(); + assert.equal((await exited).code, TIMEOUT_EXIT_CODE); + assert.equal(await waitForDeath(pid), true); + } +); test("the runtime is stopped when the supervisor named by ATOM_PARENT_PID dies", async () => { - const supervisor = spawn(process.execPath, ["-e", WAIT_FOREVER], { stdio: "ignore" }); + const supervisor = spawn(process.execPath, ["-e", WAIT_FOREVER], { + stdio: "ignore" + }); const { exited, runtimeInfo } = startDispatcher( { ATOM_PARENT_PID: String(supervisor.pid) }, WAIT_FOREVER @@ -169,12 +203,16 @@ test("the runtime is stopped when the supervisor named by ATOM_PARENT_PID dies", assert.equal(await waitForDeath(pid), true); }); -test("SIGTERM to the dispatcher is forwarded and waited for", { skip: isWin }, async () => { - const { child, exited, runtimeInfo } = startDispatcher({}, WAIT_FOREVER); - const { pid } = await runtimeInfo(); - child.kill("SIGTERM"); - const result = await exited; - // The stand-in runtime dies from the forwarded SIGTERM; the dispatcher reports that as 128 + 15. - assert.equal(result.code, 143); - assert.equal(isAlive(pid), false); -}); +test( + "SIGTERM to the dispatcher is forwarded and waited for", + { skip: isWin }, + async () => { + const { child, exited, runtimeInfo } = startDispatcher({}, WAIT_FOREVER); + const { pid } = await runtimeInfo(); + child.kill("SIGTERM"); + const result = await exited; + // The stand-in runtime dies from the forwarded SIGTERM; the dispatcher reports that as 128 + 15. + assert.equal(result.code, 143); + assert.equal(isAlive(pid), false); + } +); From 6b387f93a97d4dd45147c39b834d03e06d586ed4 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 13:48:12 +0100 Subject: [PATCH 05/11] Write slices files atomically A run stopped while writing (a timeout, a kill) left a truncated JSON file at the final path, which consumers took for a finished slice and failed to parse. Reachables chunks, the usages and data-flow slices and the empty reachables result are now written to a temporary sibling and renamed into place once complete. --- src/main/scala/io/appthreat/atom/Atom.scala | 12 +++--- .../atom/slicing/ReachableSlicing.scala | 4 +- .../appthreat/atom/slicing/SliceFiles.scala | 37 +++++++++++++++++++ .../atom/slicing/SliceFilesTests.scala | 31 ++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 src/main/scala/io/appthreat/atom/slicing/SliceFiles.scala create mode 100644 src/test/scala/io/appthreat/atom/slicing/SliceFilesTests.scala diff --git a/src/main/scala/io/appthreat/atom/Atom.scala b/src/main/scala/io/appthreat/atom/Atom.scala index f0bd32aa..8ed1f803 100644 --- a/src/main/scala/io/appthreat/atom/Atom.scala +++ b/src/main/scala/io/appthreat/atom/Atom.scala @@ -988,8 +988,8 @@ object Atom: runChennaiTags(config, ag) val slice = calculateUsagesSlice(ag, config) slice.foreach { s => - val outFile = config.outputSliceFile.createFileIfNotExists(createParents = true) - s.toJsonFile(outFile) + val outFile = config.outputSliceFile + SliceFiles.writeAtomically(outFile)(s.toJsonFile) println(s"Slices have been successfully written to ${outFile.pathAsString}") } handleEndpointExtraction(config, usagesConfig) @@ -1078,11 +1078,9 @@ object Atom: private def saveSlice(outFile: File, programSlice: Option[String]): Unit = programSlice.foreach { slice => - val finalOutputPath = - File(outFile.pathAsString) - .createFileIfNotExists(createParents = true) - .write(slice) - .pathAsString + val target = File(outFile.pathAsString) + SliceFiles.writeAtomically(target)(_.write(slice)) + val finalOutputPath = target.pathAsString println(s"Slices have been successfully written to $finalOutputPath") } diff --git a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala index a137bf83..a7c46f07 100644 --- a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala +++ b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala @@ -224,12 +224,12 @@ object ReachableSlicing: hasFlows = true val fileName = if index == 0 then s"$outputBasePath.json" else s"${outputBasePath}_$index.json" - File(fileName).writeText(chunk.asJson.noSpaces) + SliceFiles.writeAtomically(File(fileName))(_.writeText(chunk.asJson.noSpaces)) } if !hasFlows then handleEmptySlices(atom, config) - File(s"$outputBasePath.json").writeText("[]") + SliceFiles.writeAtomically(File(s"$outputBasePath.json"))(_.writeText("[]")) end calculateReachableSliceAndPersist /** Delete the numbered chunks (`_1.json`, `_2.json`, ...) written for this base path. diff --git a/src/main/scala/io/appthreat/atom/slicing/SliceFiles.scala b/src/main/scala/io/appthreat/atom/slicing/SliceFiles.scala new file mode 100644 index 00000000..710b7118 --- /dev/null +++ b/src/main/scala/io/appthreat/atom/slicing/SliceFiles.scala @@ -0,0 +1,37 @@ +package io.appthreat.atom.slicing + +import better.files.File + +import java.nio.file.{AtomicMoveNotSupportedException, Files, StandardCopyOption} +import scala.util.control.NonFatal + +/** Writing slices files so that a reader never sees half of one. */ +object SliceFiles: + + /** Write `target` through a temporary sibling that is renamed into place once complete. + * + * A run stopped while writing (a timeout, a kill) otherwise leaves a truncated JSON file at the + * final path, which consumers take for a finished slice and then fail to parse. The rename is + * atomic where the filesystem supports it, and a plain replace where it does not. + */ + def writeAtomically(target: File)(write: File => Unit): Unit = + Option(target.parent).foreach(_.createDirectoryIfNotExists(createParents = true)) + val temp = target.sibling(s".${target.name}.${ProcessHandle.current().pid()}.tmp") + try + write(temp) + try + Files.move( + temp.path, + target.path, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + catch + case _: AtomicMoveNotSupportedException => + Files.move(temp.path, target.path, StandardCopyOption.REPLACE_EXISTING) + catch + case NonFatal(e) => + temp.delete(swallowIOExceptions = true) + throw e + end writeAtomically +end SliceFiles diff --git a/src/test/scala/io/appthreat/atom/slicing/SliceFilesTests.scala b/src/test/scala/io/appthreat/atom/slicing/SliceFilesTests.scala new file mode 100644 index 00000000..804d4366 --- /dev/null +++ b/src/test/scala/io/appthreat/atom/slicing/SliceFilesTests.scala @@ -0,0 +1,31 @@ +package io.appthreat.atom.slicing + +import better.files.File +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** A slices file is either the previous complete one or the new complete one, never a fragment. */ +class SliceFilesTests extends AnyFunSuite with Matchers: + + test("a completed write replaces the target and leaves no temporary file"): + File.usingTemporaryDirectory("atom-slice-files") { dir => + val target = dir / "sub" / "python-usages.slices.json" + SliceFiles.writeAtomically(target)(_.writeText("""{"objectSlices":[]}""")) + target.contentAsString shouldBe """{"objectSlices":[]}""" + SliceFiles.writeAtomically(target)(_.writeText("[]")) + target.contentAsString shouldBe "[]" + (dir / "sub").list.map(_.name).toList shouldBe List("python-usages.slices.json") + } + + test("a write that fails part-way leaves the previous file in place"): + File.usingTemporaryDirectory("atom-slice-files") { dir => + val target = dir / "python-reachables.slices.json" + target.writeText("""[{"flows":[]}]""") + an[IllegalStateException] should be thrownBy SliceFiles.writeAtomically(target) { temp => + temp.writeText("""[{"flo""") + throw IllegalStateException("stopped while writing") + } + target.contentAsString shouldBe """[{"flows":[]}]""" + dir.list.map(_.name).toList shouldBe List("python-reachables.slices.json") + } +end SliceFilesTests From 3cc13160d11efeb83d4d25692448942a58ef01b4 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 13:48:12 +0100 Subject: [PATCH 06/11] Kill the runtime on a second signal to the dispatcher The first SIGINT/SIGTERM is relayed so atom can stop cleanly. A runtime that ignored it kept the dispatcher waiting with no way out short of killing the dispatcher itself; a second signal now kills the runtime. --- wrapper/nodejs/packages/atom/supervise.js | 9 ++++++++- wrapper/nodejs/test/supervise.test.js | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/wrapper/nodejs/packages/atom/supervise.js b/wrapper/nodejs/packages/atom/supervise.js index 5552c2f0..28786afc 100644 --- a/wrapper/nodejs/packages/atom/supervise.js +++ b/wrapper/nodejs/packages/atom/supervise.js @@ -94,6 +94,7 @@ export function supervisorWatch(env = process.env, probe = defaultProbe) { * status. The dispatcher only relays, so it must never leave the runtime behind: * * - signals sent to the dispatcher are forwarded, and it keeps waiting until the runtime exits; + * a second signal kills the runtime; * - ATOM_TIMEOUT (milliseconds) stops the runtime (SIGTERM, then SIGKILL after * ATOM_KILL_GRACE_MS, default 10 s) and exits with TIMEOUT_EXIT_CODE; * - when the supervisor named by ATOM_PARENT_PID goes away, the runtime is stopped the same way; @@ -132,8 +133,14 @@ export function superviseRuntime(command, args, env, cwd) { child.kill("SIGTERM"); killTimer = setTimeout(() => child.kill("SIGKILL"), killGraceMs); }; + // The first signal is relayed so the runtime can stop cleanly; a second one means the caller + // has stopped waiting for that, and the runtime is killed. + let signalsReceived = 0; const forwarders = FORWARDED_SIGNALS.map((signal) => { - const forward = () => child.kill(signal); + const forward = () => { + signalsReceived++; + child.kill(signalsReceived > 1 ? "SIGKILL" : signal); + }; process.on(signal, forward); return [signal, forward]; }); diff --git a/wrapper/nodejs/test/supervise.test.js b/wrapper/nodejs/test/supervise.test.js index 477e9533..1d5cad23 100644 --- a/wrapper/nodejs/test/supervise.test.js +++ b/wrapper/nodejs/test/supervise.test.js @@ -216,3 +216,23 @@ test( assert.equal(isAlive(pid), false); } ); + +test( + "a second signal to the dispatcher kills a runtime that ignores the first", + { skip: isWin }, + async () => { + const { child, exited, runtimeInfo } = startDispatcher( + {}, + `process.on("SIGTERM", () => {}); ${WAIT_FOREVER}` + ); + const { pid } = await runtimeInfo(); + child.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 300)); + assert.equal(isAlive(pid), true, "the first SIGTERM is only relayed"); + child.kill("SIGTERM"); + const result = await exited; + // The runtime dies from SIGKILL; the dispatcher reports that as 128 + 9. + assert.equal(result.code, 137); + assert.equal(await waitForDeath(pid), true); + } +); From 27780fd931d985094f8b75fdbb585de5d490e4b8 Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 14:44:40 +0100 Subject: [PATCH 07/11] Remove a leftover temporary slices file along with stale chunks A run killed between writing a reachables file and renaming it into place left its hidden temporary file behind; the next run's chunk cleanup now removes it too. --- .../io/appthreat/atom/slicing/ReachableSlicing.scala | 12 +++++++++--- .../atom/slicing/ReachableChunkFilesTests.scala | 10 +++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala index a7c46f07..7301c0a2 100644 --- a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala +++ b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala @@ -237,11 +237,17 @@ object ReachableSlicing: private[slicing] def removeChunkFiles(outputBasePath: String): Unit = val base = File(outputBasePath) val prefix = s"${base.name}_" + // A run killed between writing a slices file and renaming it into place leaves its hidden + // temporary file (see SliceFiles.writeAtomically) next to the chunks; it goes with them. + val tempPrefix = s".${base.name}" Option(base.parent).filter(_.isDirectory).foreach { dir => dir.list(f => - f.isRegularFile && f.name.startsWith(prefix) && f.name.endsWith(".json") && - f.name.stripPrefix(prefix).stripSuffix(".json").forall(_.isDigit) && - f.name.length > prefix.length + ".json".length + f.isRegularFile && ( + (f.name.startsWith(prefix) && f.name.endsWith(".json") && + f.name.stripPrefix(prefix).stripSuffix(".json").forall(_.isDigit) && + f.name.length > prefix.length + ".json".length) || + (f.name.startsWith(tempPrefix) && f.name.endsWith(".tmp")) + ) ).foreach(_.delete(swallowIOExceptions = true)) } diff --git a/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala index 346b142b..946bad3d 100644 --- a/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala +++ b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala @@ -7,7 +7,7 @@ import org.scalatest.matchers.should.Matchers /** A reachables run must not leave the numbered chunks of an earlier, larger run behind. */ class ReachableChunkFilesTests extends AnyFunSuite with Matchers: - test("only the numbered chunks of the base path are removed"): + test("only the numbered chunks and leftover temporary files of the base path are removed"): File.usingTemporaryDirectory("atom-chunks") { dir => val names = Seq( "python-reachables.slices.json", @@ -16,7 +16,10 @@ class ReachableChunkFilesTests extends AnyFunSuite with Matchers: "python-reachables.slices_.json", "python-reachables.slices_x.json", "python-reachables.slices_1.json.bak", - "js-reachables.slices_1.json" + "js-reachables.slices_1.json", + ".python-reachables.slices.json.4242.tmp", + ".python-reachables.slices_3.json.4242.tmp", + ".js-reachables.slices.json.4242.tmp" ) names.foreach(n => (dir / n).writeText("[]")) ReachableSlicing.removeChunkFiles((dir / "python-reachables.slices").pathAsString) @@ -25,7 +28,8 @@ class ReachableChunkFilesTests extends AnyFunSuite with Matchers: "python-reachables.slices_.json", "python-reachables.slices_x.json", "python-reachables.slices_1.json.bak", - "js-reachables.slices_1.json" + "js-reachables.slices_1.json", + ".js-reachables.slices.json.4242.tmp" ) } From bd91d9cbaf75fdaec4cc75006f7375adaa6d3e0a Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 15:18:04 +0100 Subject: [PATCH 08/11] List only the output directory's own entries when removing stale chunks better-files' list(filter) walks the whole tree. With the slices file in /tmp it reached unreadable systemd-private directories and failed the reachables run, and in a project directory it would delete same-named chunks in subdirectories. Clean-up is best effort, so a directory that cannot be listed no longer fails the run. --- .../atom/slicing/ReachableSlicing.scala | 26 +++++++++++++------ .../slicing/ReachableChunkFilesTests.scala | 25 ++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala index 7301c0a2..1ae3f0e5 100644 --- a/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala +++ b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala @@ -13,7 +13,10 @@ import io.shiftleft.codepropertygraph.generated.nodes.* import io.shiftleft.semanticcpg.language.* import java.io.{BufferedWriter, FileWriter, File as JFile} +import java.nio.file.Files import java.util.regex.Pattern +import scala.jdk.CollectionConverters.* +import scala.util.{Try, Using} import io.circe.generic.auto.* import io.circe.syntax.* @@ -240,16 +243,23 @@ object ReachableSlicing: // A run killed between writing a slices file and renaming it into place leaves its hidden // temporary file (see SliceFiles.writeAtomically) next to the chunks; it goes with them. val tempPrefix = s".${base.name}" + def isStale(f: File) = + f.isRegularFile && ( + (f.name.startsWith(prefix) && f.name.endsWith(".json") && + f.name.stripPrefix(prefix).stripSuffix(".json").forall(_.isDigit) && + f.name.length > prefix.length + ".json".length) || + (f.name.startsWith(tempPrefix) && f.name.endsWith(".tmp")) + ) + // Only the directory's own entries. better-files' `list(filter)` walks the whole tree below + // it, which reaches unreadable directories (`/tmp/systemd-private-*`) and would delete + // same-named files in subdirectories. Clean-up is best effort: a directory that cannot be + // listed does not fail the run. Option(base.parent).filter(_.isDirectory).foreach { dir => - dir.list(f => - f.isRegularFile && ( - (f.name.startsWith(prefix) && f.name.endsWith(".json") && - f.name.stripPrefix(prefix).stripSuffix(".json").forall(_.isDigit) && - f.name.length > prefix.length + ".json".length) || - (f.name.startsWith(tempPrefix) && f.name.endsWith(".tmp")) - ) - ).foreach(_.delete(swallowIOExceptions = true)) + Try(Using.resource(Files.newDirectoryStream(dir.path)) { entries => + entries.asScala.map(File(_)).filter(isStale).toList + }).getOrElse(Nil).foreach(_.delete(swallowIOExceptions = true)) } + end removeChunkFiles /** Force every node's in/out edges (and their endpoints) to materialise, single-threaded. See the * call site for why this precedes the parallel query engine. diff --git a/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala index 946bad3d..b30fbcb1 100644 --- a/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala +++ b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala @@ -33,6 +33,31 @@ class ReachableChunkFilesTests extends AnyFunSuite with Matchers: ) } + test("files in subdirectories are kept, and an unreadable subdirectory is not an error"): + File.usingTemporaryDirectory("atom-chunks") { dir => + (dir / "python-reachables.slices_1.json").writeText("[]") + val nested = (dir / "other-project").createDirectory() + (nested / "python-reachables.slices_1.json").writeText("[]") + (nested / ".python-reachables.slices.json.4242.tmp").writeText("[]") + // Like /tmp/systemd-private-*: present in the output directory, but not readable. + val locked = (dir / "systemd-private-4419").createDirectory() + (locked / "python-reachables.slices_2.json").writeText("[]") + locked.toJava.setReadable(false, false) + locked.toJava.setExecutable(false, false) + try + noException should be thrownBy ReachableSlicing.removeChunkFiles( + (dir / "python-reachables.slices").pathAsString + ) + (dir / "python-reachables.slices_1.json").exists shouldBe false + nested.list.map(_.name).toSet shouldBe Set( + "python-reachables.slices_1.json", + ".python-reachables.slices.json.4242.tmp" + ) + finally + locked.toJava.setExecutable(true, false) + locked.toJava.setReadable(true, false) + } + test("a base path in a missing directory is a no-op"): noException should be thrownBy ReachableSlicing.removeChunkFiles( "/nonexistent-dir-4419/a.slices" From e7ba6ca611492037c04528c207488b936b5ec4be Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 15:18:04 +0100 Subject: [PATCH 09/11] ci: point sbt in the musl build container at the mounted Ivy home Java takes user.home from the passwd entry (/root), not from HOME, so sbt looked in /root/.ivy2/local and missed the chen that the chen-from-main step publishes into the Ivy directory mounted at /tmp/home/.ivy2. --- ci/native-image-musl.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ci/native-image-musl.sh b/ci/native-image-musl.sh index 10d9ae8c..c48fbf26 100755 --- a/ci/native-image-musl.sh +++ b/ci/native-image-musl.sh @@ -140,9 +140,11 @@ echo "Building linux/${ARCH} musl native image with ${ENGINE} using ${IMAGE}" if [ ! -f /usr/local/bin/sbt-launch.jar ]; then curl -fsSL https://repo1.maven.org/maven2/org/scala-sbt/sbt-launch/1.12.11/sbt-launch-1.12.11.jar -o /usr/local/bin/sbt-launch.jar fi + # Java takes user.home from the passwd entry (/root), not from HOME, so without it sbt misses + # the Ivy repository mounted under HOME, and with it the locally published chen. cat > /usr/local/bin/sbt <<"SBT" #!/usr/bin/env bash -exec java -Xms512M -Xmx4G -jar /usr/local/bin/sbt-launch.jar "$@" +exec java -Xms512M -Xmx4G -Duser.home="$HOME" -jar /usr/local/bin/sbt-launch.jar "$@" SBT chmod +x /usr/local/bin/sbt sbt "GraalVMNativeImage / packageBin" From 08fb582d66eb07575527a7f9a7590193802e9aac Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 15:33:15 +0100 Subject: [PATCH 10/11] Bump atom-parsetools to 1.8.0 in the container images 1.8.0 stops a helper and the processes it started once its supervisor is gone. --- ci/Dockerfile | 2 +- ci/Dockerfile-alpine-ruby | 2 +- ci/Dockerfile-alpine-ruby3 | 2 +- ci/Dockerfile-ruby | 2 +- ci/Dockerfile-ruby3 | 2 +- ci/Dockerfile-sle | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ci/Dockerfile b/ci/Dockerfile index b8ef7ad0..c515cb61 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -100,7 +100,7 @@ RUN set -e; \ && curl -LO https://raw.githubusercontent.com/AppThreat/chen/main/platform/frontends/php2atom/composer.json \ && composer update --no-progress --prefer-dist --ignore-platform-reqs \ && rm composer.json composer.lock composer-setup.php \ - && npm install -g @appthreat/atom-parsetools@1.7.0 \ + && npm install -g @appthreat/atom-parsetools@1.8.0 \ && which astgen \ && which phpastgen \ && phpastgen --parser-info \ diff --git a/ci/Dockerfile-alpine-ruby b/ci/Dockerfile-alpine-ruby index 3faf9657..403c10a5 100644 --- a/ci/Dockerfile-alpine-ruby +++ b/ci/Dockerfile-alpine-ruby @@ -17,7 +17,7 @@ WORKDIR /opt COPY ./target/atom.zip target/atom.zip COPY ./wrapper wrapper RUN apk update && apk add --no-cache git build-base ruby-dev bash nodejs npm ruby ruby-bundler openjdk25 python3 python3-dev py3-pip \ - && npm install -g corepack @appthreat/atom-parsetools@1.7.0 \ + && npm install -g corepack @appthreat/atom-parsetools@1.8.0 \ && node -v \ && npm -v \ && cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar && cd /opt \ diff --git a/ci/Dockerfile-alpine-ruby3 b/ci/Dockerfile-alpine-ruby3 index 070bbca5..ccb72858 100644 --- a/ci/Dockerfile-alpine-ruby3 +++ b/ci/Dockerfile-alpine-ruby3 @@ -17,7 +17,7 @@ WORKDIR /opt COPY ./target/atom.zip target/atom.zip COPY ./wrapper wrapper RUN apk update && apk add --no-cache git build-base ruby-dev bash nodejs npm ruby ruby-bundler openjdk25 python3 python3-dev py3-pip \ - && npm install -g corepack @appthreat/atom-parsetools@1.7.0 \ + && npm install -g corepack @appthreat/atom-parsetools@1.8.0 \ && node -v \ && npm -v \ && cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar && cd /opt \ diff --git a/ci/Dockerfile-ruby b/ci/Dockerfile-ruby index 437dd1ca..494b5063 100644 --- a/ci/Dockerfile-ruby +++ b/ci/Dockerfile-ruby @@ -52,7 +52,7 @@ RUN set -e; \ && node --version \ && python3 -m pip install --upgrade pip atom-tools \ && atom-tools --version && atom-tools --help \ - && cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar @appthreat/atom-parsetools@1.7.0 && cd /opt \ + && cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar @appthreat/atom-parsetools@1.8.0 && cd /opt \ && rm -rf target \ && /usr/local/bin/atom --help \ && microdnf remove -y unzip gcc git-core make \ diff --git a/ci/Dockerfile-ruby3 b/ci/Dockerfile-ruby3 index 47444766..a7a6c1af 100644 --- a/ci/Dockerfile-ruby3 +++ b/ci/Dockerfile-ruby3 @@ -52,7 +52,7 @@ RUN set -e; \ && node --version \ && python3 -m pip install --upgrade pip atom-tools \ && atom-tools --version && atom-tools --help \ - && cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar @appthreat/atom-parsetools@1.7.0 && cd /opt \ + && cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar @appthreat/atom-parsetools@1.8.0 && cd /opt \ && rm -rf targert \ && /usr/local/bin/atom --help \ && microdnf remove -y unzip gcc git-core make \ diff --git a/ci/Dockerfile-sle b/ci/Dockerfile-sle index a8822bdd..366db89e 100644 --- a/ci/Dockerfile-sle +++ b/ci/Dockerfile-sle @@ -73,7 +73,7 @@ ENV LC_ALL=en_US.UTF-8 \ LANGUAGE=en_US.UTF-8 COPY ./target/atom.zip target/atom.zip COPY ./wrapper wrapper -RUN cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar @appthreat/atom-parsetools@1.7.0 && cd /opt \ +RUN cd /opt/wrapper/nodejs && bash build.sh && npm install -g ./packages/atom --omit=optional && npm install -g ./packages/atom-jar @appthreat/atom-parsetools@1.8.0 && cd /opt \ && rm -rf target \ && java --version \ && ls -alh /opt/ \ From 23cbb786d86f1e100d819d84e574801390e8beae Mon Sep 17 00:00:00 2001 From: Prabhu Subramanian Date: Thu, 1 Oct 2026 16:25:13 +0100 Subject: [PATCH 11/11] ci: build chen from main before assembling the npm packages The npm-publish job was the only sbt job without the temporary chen-from-main step, so it could not resolve chen 4.0.0. --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81d5d3bd..74c2b4c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -449,6 +449,7 @@ jobs: distribution: 'temurin' java-version: '23' - uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6 + - uses: ./.github/actions/chen-from-main # temporary: remove once chen 4.0.0 is published - name: Install deps run: | sudo apt update -y