All Downloads are FREE. Search and download functionalities are using the official Maven repository.

scala.cli.packaging.NativeImage.scala Maven / Gradle / Ivy

There is a newer version: 1.5.0
Show newest version
package scala.cli.packaging

import java.io.File

import scala.annotation.tailrec
import scala.build.internal.{ManifestJar, Runner}
import scala.build.internals.EnvVar
import scala.build.{Build, Logger, Positioned}
import scala.cli.errors.GraalVMNativeImageError
import scala.cli.graal.{BytecodeProcessor, TempCache}
import scala.cli.internal.CachedBinary
import scala.util.Properties

object NativeImage {

  private def ensureHasNativeImageCommand(
    graalVMHome: os.Path,
    logger: Logger
  ): os.Path = {

    val ext         = if (Properties.isWin) ".cmd" else ""
    val nativeImage = graalVMHome / "bin" / s"native-image$ext"

    if (os.exists(nativeImage))
      logger.debug(s"$nativeImage found")
    else {
      val proc = os.proc(graalVMHome / "bin" / s"gu$ext", "install", "native-image")
      logger.debug(s"$nativeImage not found, running ${proc.command.flatMap(_.value)}")
      proc.call(stdin = os.Inherit, stdout = os.Inherit)
      if (!os.exists(nativeImage))
        logger.message(
          s"Seems gu install command didn't install $nativeImage, trying to run it anyway"
        )
    }

    nativeImage
  }

  private def vcVersions = Seq("2022", "2019", "2017")
  private def vcEditions = Seq("Enterprise", "Community", "BuildTools")
  private lazy val vcVarsCandidates: Iterable[String] =
    EnvVar.Misc.vcVarsAll.valueOpt ++ {
      for {
        isX86   <- Seq(false, true)
        version <- vcVersions
        edition <- vcEditions
      } yield {
        val programFiles = if (isX86) "Program Files (x86)" else "Program Files"
        """C:\""" + programFiles + """\Microsoft Visual Studio\""" + version + "\\" + edition + """\VC\Auxiliary\Build\vcvars64.bat"""
      }
    }

  private def vcvarsOpt: Option[os.Path] =
    vcVarsCandidates
      .iterator
      .map(os.Path(_, os.pwd))
      .filter(os.exists(_))
      .take(1)
      .toList
      .headOption

  private def runFromVcvarsBat(
    command: Seq[String],
    vcvars: os.Path,
    workingDir: os.Path,
    logger: Logger
  ): Int = {
    logger.debug(s"Using vcvars script $vcvars")
    val escapedCommand = command.map {
      case s if s.contains(" ") => "\"" + s + "\""
      case s                    => s
    }
    // chcp 437 sometimes needed, see https://github.com/oracle/graal/issues/2522
    val script =
      s"""chcp 437
         |@call "$vcvars"
         |if %errorlevel% neq 0 exit /b %errorlevel%
         |@call ${escapedCommand.mkString(" ")}
         |""".stripMargin
    logger.debug(s"Native image script: '$script'")
    val scriptPath = workingDir / "run-native-image.bat"
    logger.debug(s"Writing native image script at $scriptPath")
    os.write.over(scriptPath, script.getBytes, createFolders = true)

    val finalCommand = Seq("cmd", "/c", scriptPath.toString)
    logger.debug(s"Running $finalCommand")
    val res = os.proc(finalCommand).call(
      cwd = os.pwd,
      check = false,
      stdin = os.Inherit,
      stdout = os.Inherit
    )
    logger.debug(s"Command $finalCommand exited with exit code ${res.exitCode}")

    res.exitCode
  }

  private lazy val mountedDrives: String = {
    val str         = "HKEY_LOCAL_MACHINE/SYSTEM/MountedDevices".replace('/', '\\')
    val queryDrives = s"reg query $str"
    val lines       = os.proc("cmd", "/c", queryDrives).call().out.lines()
    val dosDevices = lines.filter { s =>
      s.contains("DosDevices")
    }.map { s =>
      s.replaceAll(".DosDevices.", "").replaceAll(":.*", "")
    }
    dosDevices.mkString
  }
  private def availableDriveLetter(): Char = {

    @tailrec
    def helper(from: Char): Char =
      if (from > 'Z') sys.error("Cannot find free drive letter")
      else if (mountedDrives.contains(from)) helper((from + 1).toChar)
      else from

    helper('D')
  }

  /** Alias currentHome to the root of a drive, so that its files can be accessed with shorter paths
    * (hopefully not going above the ~260 char limit of some Windows apps, such as cl.exe).
    *
    * Couldn't manage to make
    * https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=powershell#enable-long-paths-in-windows-10-version-1607-and-later
    * work, so I went down the path here.
    */
  private def maybeWithShorterGraalvmHome[T](
    currentHome: os.Path,
    logger: Logger
  )(
    f: os.Path => T
  ): T =
    // not sure about the 180 limit, we might need to lower it
    if (Properties.isWin && currentHome.toString.length >= 180) {
      val driveLetter = availableDriveLetter()
      // aliasing the parent dir, as it seems GraalVM native-image (as of 22.0.0)
      // isn't fine with being put at the root of a drive - it tries to look for
      // things like 'D:lib' (missing '\') at some point.
      val from      = currentHome / os.up
      val drivePath = os.Path(s"$driveLetter:" + "\\")
      val newHome   = drivePath / currentHome.last
      logger.debug(s"Aliasing $from to $drivePath")
      val setupCommand  = s"""subst $driveLetter: "$from""""
      val disableScript = s"""subst $driveLetter: /d"""

      os.proc("cmd", "/c", setupCommand).call(stdin = os.Inherit, stdout = os.Inherit)
      try f(newHome)
      finally {
        val res = os.proc("cmd", "/c", disableScript).call(
          stdin = os.Inherit,
          stdout = os.Inherit,
          check = false
        )
        if (res.exitCode == 0)
          logger.debug(s"Unaliased $drivePath")
        else if (os.exists(drivePath)) {
          // ignore errors?
          logger.debug(s"Unaliasing attempt exited with exit code ${res.exitCode}")
          throw new os.SubprocessException(res)
        }
        else
          logger.debug(
            s"Failed to unalias $drivePath which seems not to exist anymore, ignoring it"
          )
      }
    }
    else
      f(currentHome)

  def buildNativeImage(
    build: Build.Successful,
    mainClass: String,
    dest: os.Path,
    nativeImageWorkDir: os.Path,
    extraOptions: Seq[String],
    logger: Logger
  ): Unit = {

    os.makeDir.all(nativeImageWorkDir)

    val jvmId = build.options.notForBloopOptions.packageOptions.nativeImageOptions.jvmId
    val options = build.options.copy(
      javaOptions = build.options.javaOptions.copy(
        jvmIdOpt = Some(Positioned.none(jvmId))
      )
    )

    val javaHome = options.javaHome().value
    val nativeImageArgs =
      options.notForBloopOptions.packageOptions.nativeImageOptions.graalvmArgs.map(_.value)

    val cacheData = CachedBinary.getCacheData(
      build,
      s"--java-home=${javaHome.javaHome.toString}" :: "--" :: extraOptions.toList ++ nativeImageArgs,
      dest,
      nativeImageWorkDir
    )

    if (cacheData.changed) {
      val mainJar           = Library.libraryJar(build)
      val originalClassPath = mainJar +: build.dependencyClassPath

      ManifestJar.maybeWithManifestClassPath(
        createManifest = Properties.isWin,
        classPath = originalClassPath,
        // seems native-image doesn't correctly parse paths in manifests - this is especially a problem on Windows
        wrongSimplePathsInManifest = true
      ) { processedClassPath =>
        val needsProcessing = build.scalaParams.exists(_.scalaVersion.startsWith("3."))
        val (classPath, toClean, scala3extraOptions) =
          if (needsProcessing) {
            val cpString         = processedClassPath.mkString(File.pathSeparator)
            val processed        = BytecodeProcessor.processClassPath(cpString, TempCache).toSeq
            val nativeConfigFile = os.temp(suffix = ".json")
            os.write.over(
              nativeConfigFile,
              """[
                |  {
                |    "name": "sun.misc.Unsafe",
                |    "allDeclaredConstructors": true,
                |    "allPublicConstructors": true,
                |    "allDeclaredMethods": true,
                |    "allDeclaredFields": true
                |  }
                |]
                |""".stripMargin
            )
            val cp      = processed.map(_.path)
            val options = Seq(s"-H:ReflectionConfigurationFiles=$nativeConfigFile")

            (cp, nativeConfigFile +: BytecodeProcessor.toClean(processed), options)
          }
          else
            (processedClassPath, Seq[os.Path](), Seq[String]())

        try {
          val args = extraOptions ++ scala3extraOptions ++ Seq(
            s"-H:Path=${dest / os.up}",
            s"-H:Name=${dest.last.stripSuffix(".exe")}", // FIXME Case-insensitive strip suffix?
            "-cp",
            classPath.map(_.toString).mkString(File.pathSeparator),
            mainClass
          ) ++ nativeImageArgs

          maybeWithShorterGraalvmHome(javaHome.javaHome, logger) { graalVMHome =>

            val nativeImageCommand = ensureHasNativeImageCommand(graalVMHome, logger)
            val command            = nativeImageCommand.toString +: args

            val exitCode =
              if (Properties.isWin)
                vcvarsOpt match {
                  case Some(vcvars) =>
                    runFromVcvarsBat(command, vcvars, nativeImageWorkDir, logger)
                  case None =>
                    Runner.run(command, logger).waitFor()
                }
              else
                Runner.run(command, logger).waitFor()
            if (exitCode == 0) {
              val actualDest =
                if (Properties.isWin)
                  if (dest.last.endsWith(".exe")) dest
                  else dest / os.up / s"${dest.last}.exe"
                else
                  dest
              CachedBinary.updateProjectAndOutputSha(
                actualDest,
                nativeImageWorkDir,
                cacheData.projectSha
              )
            }
            else
              throw new GraalVMNativeImageError
          }
        }
        finally util.Try(toClean.foreach(os.remove.all))
      }
    }
    else
      logger.message("Found cached native image binary.")
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy