Skip to content
Draft
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
98 changes: 77 additions & 21 deletions modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,24 @@ object NativeImage {
}
dosDevices.mkString
}

private def availableDriveLetter(): Char = {
// if a drive letter has already been mapped by SUBST, it isn't free
val substDrives: Set[Char] =
os.proc("cmd", "/c", "subst").call().out.text()
.linesIterator
.flatMap { line =>
// lines look like: "I:\: => C:\path"
if (line.length >= 2 && line(1) == ':') Some(line(0))
else None
}
.toSet

@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 if (mountedDrives.contains(from) || substDrives.contains(from))
helper((from + 1).toChar)
else from

helper('D')
Expand Down Expand Up @@ -149,33 +161,77 @@ object NativeImage {
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"""
val setupCommand = s"""subst $driveLetter: "$from""""
val disableScript = s"""subst $driveLetter: /d"""
val savedCodepage: String = getCodePage(logger) // before visual studio sets code page to 437

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)
def atexitCleanup(): Unit = {
import java.lang.ProcessBuilder
try {
restoreCodePage(savedCodepage, logger) // best effort

// cannot use os.proc() because it also installs a shutdown hook
val pb = new ProcessBuilder("cmd.exe", "/c", disableScript)
pb.inheritIO()
val p = pb.start()
val exit = p.waitFor()

if (exit == 0)
logger.debug(s"Unaliased $drivePath")
else if (os.exists(drivePath))
logger.error(s"Unaliasing attempt exited with exit code $exit")
// no throw in a shutdown hook, it might obscure the real cause of the shutdown
else
logger.debug(
s"Failed to unalias $drivePath which seems not to exist anymore, ignoring it"
)
}
catch {
case e: Throwable =>
logger.error(s"Cleanup failed: ${e.getMessage}")
}
else
logger.debug(
s"Failed to unalias $drivePath which seems not to exist anymore, ignoring it"
)
}
// Runs on JVM shutdown, including Ctrl-C; more reliable than a `finally` block for cleanup
Runtime.getRuntime.addShutdownHook(new Thread(() => atexitCleanup()))

os.proc("cmd", "/c", setupCommand).call(stdin = os.Inherit, stdout = os.Inherit)
f(newHome) // no need for a try block
}
else
f(currentHome)

def restoreCodePage(cp: String, logger: Logger): Unit =
if (Properties.isWin && cp.nonEmpty)
exec(Seq("cmd", "/c", "chcp", cp), logger, s"restore code page to $cp")

// execute a fire-and-forget windows command without using os.proc during shutdown.
private def exec(cmd: Seq[String], logger: Logger, desc: String): Unit = {
try {
val pb = new ProcessBuilder(cmd*)
pb.inheritIO()
val p = pb.start()
val exit = p.waitFor()
if (exit == 0)
logger.debug(s"$desc succeeded")
else
logger.error(s"$desc failed with exit code $exit")
}
catch {
case e: Throwable =>
logger.error(s"$desc failed: ${e.getMessage}")
}
}
private def getCodePage(logger: Logger): String =
try {
val out = os.proc("cmd", "/c", "chcp").call().out.text().trim
out.split(":").lastOption.map(_.trim).getOrElse("") // Extract the number
}
catch {
case e: Exception =>
logger.debug(s"unable to get initial code page: ${e.getMessage}")
""
}

def buildNativeImage(
builds: Seq[Build.Successful],
mainClass: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import java.nio.file.Files
import java.util
import java.util.zip.ZipFile

import scala.cli.integration.TestUtil.removeAnsiColors
import scala.cli.integration.TestUtil.*
import scala.jdk.CollectionConverters.*
import scala.util.{Properties, Using}

Expand Down Expand Up @@ -998,6 +998,59 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio
}
}

if (Properties.isWin)
test("availableDriveLetter") {
val message = "Hello from native-image"
val dest = "hello"
val actualDest =
if (Properties.isWin) "hello.exe"
else "hello"
val inputs = TestInputs(
os.rel / "Hello.scala" ->
s"""object Hello {
| def main(args: Array[String]): Unit =
| println("$message")
|}
|""".stripMargin
)
setCodePage("65001")
val codePageBefore = getCodePage
val driveLetter = availableDriveLetter()
val substedBefore = substedDrives
aliasDriveLetter(driveLetter, "C:\\Windows\\Temp") // trigger for #4005

inputs.fromRoot { root =>
os.proc(
TestUtil.cli,
"--power",
"package",
extraOptions,
".",
"--native-image",
"-o",
dest,
"--",
"--no-fallback"
).call(
cwd = root,
stdin = os.Inherit,
stdout = os.Inherit
)

expect(os.isFile(root / actualDest))

val res = os.proc(root / actualDest).call(cwd = root)
val output = res.out.trim()
expect(output == message)

unaliasDriveLetter(driveLetter) // undo test condition
val substedAfter = substedDrives
expect(substedBefore == substedAfter)
val codePageAfter = getCodePage
expect(codePageBefore == codePageAfter)
}
}

test("correctly list main classes") {
val (scalaFile1, scalaFile2, scriptName) = ("ScalaMainClass1", "ScalaMainClass2", "ScalaScript")
val scriptsDir = "scripts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,4 +465,73 @@ object TestUtil {
System.err.println("Cleaning cached JDKs in Coursier cache…")
cleanCoursierCache(Seq("zulu", "temurin", "adoptium", "corretto", "liberica", "graalvm"))
}

def substedDrives: Set[Char] =
if Properties.isWin then
os.proc("cmd", "/c", "subst").call().out.text()
.linesIterator
.flatMap { line =>
// lines look like: "I:\: => C:\path"
if (line.length >= 2 && line(1) == ':') Some(line(0))
else None
}
.toSet
else Set.empty[Char]

def availableDriveLetter(): Char = {
import scala.annotation.tailrec
@tailrec
def helper(from: Char): Char =
if (from > 'Z') sys.error("Cannot find free drive letter")
// neither physical drives nor SUBSTed drives are free
else if (mountedDrives.contains(from) || substedDrives.contains(from))
helper((from + 1).toChar)
else
from

helper('D')
}

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
}

def aliasDriveLetter(driveLetter: Char, from: String): Unit = {
val setupCommand = s"""subst $driveLetter: "$from""""
os.proc("cmd", "/c", setupCommand).call(stdin = os.Inherit, stdout = os.Inherit)
}

def unaliasDriveLetter(driveLetter: Char): Int = {
val disableScript = s"""subst $driveLetter: /d"""
val (exit, _) = execWindowsCmd(Seq("cmd.exe", "/c", disableScript))
exit
}

def setCodePage(cp: String): Int =
val (exit, _) = execWindowsCmd(Seq("cmd", "/c", s"chcp $cp"))
exit

def getCodePage: String = {
val (_, output) = execWindowsCmd(Seq("cmd", "/c", "chcp"))
output
}

private def execWindowsCmd(cmd: Seq[String]): (Int, String) =
val pb = new ProcessBuilder(cmd*)
pb.redirectInput(ProcessBuilder.Redirect.INHERIT)
pb.redirectError(ProcessBuilder.Redirect.INHERIT)
pb.redirectOutput(ProcessBuilder.Redirect.PIPE)
val p = pb.start()
// read stdout fully
val output = scala.io.Source.fromInputStream(p.getInputStream, "UTF-8").mkString
val exitCode = p.waitFor()
(exitCode, output)
}