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 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/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/ \ 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" 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/src/main/scala/io/appthreat/atom/Atom.scala b/src/main/scala/io/appthreat/atom/Atom.scala index 90cde2bc..8ed1f803 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 @@ -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) @@ -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") } 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/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala b/src/main/scala/io/appthreat/atom/slicing/ReachableSlicing.scala index 1fd7c448..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.* @@ -214,6 +217,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 @@ -221,14 +227,40 @@ 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. + */ + 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. */ 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/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 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..b30fbcb1 --- /dev/null +++ b/src/test/scala/io/appthreat/atom/slicing/ReachableChunkFilesTests.scala @@ -0,0 +1,65 @@ +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 and leftover temporary files 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", + ".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) + 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", + ".js-reachables.slices.json.4242.tmp" + ) + } + + 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" + ) +end ReachableChunkFilesTests 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 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..dcfe7fc1 100755 --- a/wrapper/nodejs/packages/atom/index.js +++ b/wrapper/nodejs/packages/atom/index.js @@ -2,14 +2,19 @@ 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)); -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"; @@ -50,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}`); @@ -73,67 +84,56 @@ 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..28786afc --- /dev/null +++ b/wrapper/nodejs/packages/atom/supervise.js @@ -0,0 +1,190 @@ +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; + * 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; + * - 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); + }; + // 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 = () => { + signalsReceived++; + child.kill(signalsReceived > 1 ? "SIGKILL" : 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..1d5cad23 --- /dev/null +++ b/wrapper/nodejs/test/supervise.test.js @@ -0,0 +1,238 @@ +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); + } +); + +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); + } +);