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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,9 @@ atom -o app.atom -l java --export-atom --export-dir <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. |
Expand Down
2 changes: 1 addition & 1 deletion ci/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion ci/Dockerfile-alpine-ruby
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion ci/Dockerfile-alpine-ruby3
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion ci/Dockerfile-ruby
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion ci/Dockerfile-ruby3
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion ci/Dockerfile-sle
Original file line number Diff line number Diff line change
Expand Up @@ -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/ \
Expand Down
4 changes: 3 additions & 1 deletion ci/native-image-musl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions docs/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
43 changes: 21 additions & 22 deletions src/main/scala/io/appthreat/atom/Atom.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -987,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)
Expand Down Expand Up @@ -1077,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")
}

Expand Down
98 changes: 98 additions & 0 deletions src/main/scala/io/appthreat/atom/ProcessSupervision.scala
Original file line number Diff line number Diff line change
@@ -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
36 changes: 34 additions & 2 deletions src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -214,21 +217,50 @@ 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 `<base>_N.json` until the first gap would pick up stale flows.
removeChunkFiles(outputBasePath)
val chunkedIterator = canonicalFlows.grouped(chunkSize).zipWithIndex
var hasFlows = false

chunkedIterator.foreach { case (chunk, index) =>
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 (`<base>_1.json`, `<base>_2.json`, ...) written for this base path.
*/
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}"
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 =>
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.
*/
Expand Down
37 changes: 37 additions & 0 deletions src/main/scala/io/appthreat/atom/slicing/SliceFiles.scala
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading