diff --git a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala index 2e886401b6..819f0fe01c 100644 --- a/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala +++ b/modules/cli/src/main/scala/scala/cli/packaging/NativeImage.scala @@ -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') @@ -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, diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index 4d5346af2a..b5f24f1cc3 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -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} @@ -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" diff --git a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala index 1a3e3c0396..36226dfcb9 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/TestUtil.scala @@ -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) }