Skip to content

Commit 5de7c91

Browse files
authored
fix(aot): avoid Windows CreateProcess 206 by launching AOT training with @argfile (#243)
1 parent 2b218af commit 5de7c91

2 files changed

Lines changed: 301 additions & 54 deletions

File tree

plugin-build/plugin/src/main/kotlin/io/github/kdroidfilter/nucleus/desktop/application/tasks/AbstractGenerateAotCacheTask.kt

Lines changed: 180 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,113 @@ import org.gradle.api.tasks.PathSensitivity
2222
import org.gradle.api.tasks.TaskAction
2323
import org.gradle.work.DisableCachingByDefault
2424
import java.io.File
25+
import java.io.IOException
2526

2627
private const val AOT_CACHE_FILENAME = "app.aot"
2728
private const val MIN_AOT_JDK_VERSION = 25
2829
private const val DEFAULT_SAFETY_TIMEOUT_SECONDS = 300L
2930

31+
/**
32+
* Builds the Java launcher argument list for AOT training (excluding the java executable path).
33+
*/
34+
internal fun buildAotJavaArgs(
35+
classpath: String,
36+
javaOptions: List<String>,
37+
mainClass: String,
38+
aotCacheFile: File,
39+
): List<String> =
40+
buildList {
41+
add("-XX:AOTCacheOutput=${aotCacheFile.absolutePath}")
42+
add("-Dnucleus.aot.mode=training")
43+
add("-cp")
44+
add(classpath)
45+
addAll(javaOptions)
46+
add(mainClass)
47+
}
48+
49+
/**
50+
* Writes Java launcher arguments as a UTF-8 argument file (`@argfile`), one argument per line.
51+
*/
52+
internal fun writeJavaArgFile(
53+
file: File,
54+
args: List<String>,
55+
) {
56+
val content =
57+
args
58+
.joinToString(separator = System.lineSeparator()) { arg -> escapeArgForArgFile(arg) } +
59+
System.lineSeparator()
60+
file.writeText(content, Charsets.UTF_8)
61+
}
62+
63+
/**
64+
* Escapes a single Java launcher argument for `@argfile`.
65+
*
66+
* The argument is quoted when it contains whitespace, quotes, backslashes, or is empty.
67+
* Newline characters are rejected because each argfile line encodes one argument.
68+
*/
69+
internal fun escapeArgForArgFile(arg: String): String {
70+
require('\n' !in arg && '\r' !in arg) {
71+
"Java @argfile argument must not contain newline characters"
72+
}
73+
val requiresQuotes = arg.isEmpty() || arg.any { it.isWhitespace() || it == '"' || it == '\\' }
74+
if (!requiresQuotes) return arg
75+
val escaped =
76+
arg
77+
.replace("\\", "\\\\")
78+
.replace("\"", "\\\"")
79+
return "\"$escaped\""
80+
}
81+
82+
/**
83+
* Builds candidate directories for AOT argument/log temp files.
84+
*
85+
* Preference order:
86+
* 1. `java.io.tmpdir`
87+
* 2. app directory
88+
* 3. AOT cache output directory
89+
*/
90+
internal fun buildAotTempFileCandidateDirs(
91+
appDir: File,
92+
aotCacheFile: File,
93+
tmpDirPath: String? = System.getProperty("java.io.tmpdir"),
94+
): List<File> =
95+
listOfNotNull(
96+
tmpDirPath?.takeIf { it.isNotBlank() }?.let(::File),
97+
appDir,
98+
aotCacheFile.parentFile,
99+
).map { it.absoluteFile }.distinctBy { it.path }
100+
101+
/**
102+
* Creates a temp file under candidate directories in order.
103+
*
104+
* This avoids hard dependency on `java.io.tmpdir` writability.
105+
*/
106+
internal fun createAotTempFileWithFallback(
107+
prefix: String,
108+
suffix: String,
109+
candidateDirs: List<File>,
110+
): File {
111+
var firstFailure: IOException? = null
112+
val attemptedDirs = mutableListOf<String>()
113+
for (dir in candidateDirs) {
114+
attemptedDirs += dir.absolutePath
115+
try {
116+
return File.createTempFile(prefix, suffix, dir).also { it.deleteOnExit() }
117+
} catch (e: IOException) {
118+
if (firstFailure == null) {
119+
firstFailure = e
120+
} else {
121+
firstFailure.addSuppressed(e)
122+
}
123+
}
124+
}
125+
126+
throw GradleException(
127+
"Failed to create temporary file '$prefix*$suffix' in candidate directories: ${attemptedDirs.joinToString(", ")}",
128+
firstFailure,
129+
)
130+
}
131+
30132
/**
31133
* Generates a JDK 25+ AOT cache for a Compose Desktop distributable.
32134
*
@@ -343,65 +445,89 @@ abstract class AbstractGenerateAotCacheTask : AbstractNucleusTask() {
343445
mainClass: String,
344446
aotCacheFile: File,
345447
) {
346-
val args = mutableListOf(javaExe)
347-
args += "-XX:AOTCacheOutput=${aotCacheFile.absolutePath}"
348-
args += "-Dnucleus.aot.mode=training"
349-
args += "-cp"
350-
args += classpath
351-
args += javaOptions
352-
args += mainClass
353-
354-
val logFile = File.createTempFile("nucleus-aot-", ".log")
355-
val processBuilder =
356-
ProcessBuilder(args)
357-
.directory(appDir)
358-
.redirectErrorStream(true)
359-
.redirectOutput(logFile)
360-
361-
val isLinux = System.getProperty("os.name").lowercase().contains("linux")
362-
val needsXvfb = isLinux && System.getenv("DISPLAY").isNullOrEmpty()
363-
364-
var xvfbProcess: Process? = null
365-
if (needsXvfb) {
366-
val display = ":99"
367-
xvfbProcess =
368-
ProcessBuilder("Xvfb", display, "-screen", "0", "1280x1024x24")
369-
.redirectErrorStream(true)
370-
.start()
371-
Thread.sleep(1000)
372-
processBuilder.environment()["DISPLAY"] = display
373-
logger.lifecycle("[aotCache] Started Xvfb on $display")
374-
}
448+
val javaArgs =
449+
buildAotJavaArgs(
450+
classpath = classpath,
451+
javaOptions = javaOptions,
452+
mainClass = mainClass,
453+
aotCacheFile = aotCacheFile,
454+
)
455+
val candidateDirs = buildAotTempFileCandidateDirs(appDir, aotCacheFile)
456+
var argFile: File? = null
457+
try {
458+
val javaLauncherArgs =
459+
try {
460+
argFile = createAotTempFileWithFallback("nucleus-aot-", ".args", candidateDirs)
461+
writeJavaArgFile(requireNotNull(argFile), javaArgs)
462+
listOf(javaExe, "@${requireNotNull(argFile).absolutePath}")
463+
} catch (e: GradleException) {
464+
if (isWindows()) {
465+
throw GradleException(
466+
"Failed to create AOT @argfile on Windows. " +
467+
"Cannot safely fall back to command-line arguments due to CreateProcess length limits.",
468+
e,
469+
)
470+
}
471+
logger.warn("[aotCache] Failed to create @argfile, falling back to direct Java args: ${e.message}")
472+
listOf(javaExe) + javaArgs
473+
}
375474

376-
val process = processBuilder.start()
475+
val logFile = createAotTempFileWithFallback("nucleus-aot-", ".log", candidateDirs)
476+
var xvfbProcess: Process? = null
477+
try {
478+
val processBuilder =
479+
ProcessBuilder(javaLauncherArgs)
480+
.directory(appDir)
481+
.redirectErrorStream(true)
482+
.redirectOutput(logFile)
483+
484+
val isLinux = System.getProperty("os.name").lowercase().contains("linux")
485+
val needsXvfb = isLinux && System.getenv("DISPLAY").isNullOrEmpty()
486+
if (needsXvfb) {
487+
val display = ":99"
488+
xvfbProcess =
489+
ProcessBuilder("Xvfb", display, "-screen", "0", "1280x1024x24")
490+
.redirectErrorStream(true)
491+
.start()
492+
Thread.sleep(1000)
493+
processBuilder.environment()["DISPLAY"] = display
494+
logger.lifecycle("[aotCache] Started Xvfb on $display")
495+
}
377496

378-
val deadline = System.currentTimeMillis() + safetyTimeoutSeconds.get() * 1000
379-
while (process.isAlive && System.currentTimeMillis() < deadline) {
380-
Thread.sleep(500)
381-
}
382-
if (process.isAlive) {
383-
logger.warn("[aotCache] App did not self-terminate within safety timeout, forcing kill")
384-
process.destroyForcibly()
385-
}
497+
val process = processBuilder.start()
386498

387-
val exitCode = process.waitFor()
388-
xvfbProcess?.destroyForcibly()
499+
val deadline = System.currentTimeMillis() + safetyTimeoutSeconds.get() * 1000
500+
while (process.isAlive && System.currentTimeMillis() < deadline) {
501+
Thread.sleep(500)
502+
}
503+
if (process.isAlive) {
504+
logger.warn("[aotCache] App did not self-terminate within safety timeout, forcing kill")
505+
process.destroyForcibly()
506+
}
389507

390-
val output = logFile.readText().takeLast(3000)
391-
if (output.isNotBlank()) {
392-
logger.lifecycle("[aotCache] Output (exit $exitCode):\n$output")
393-
}
394-
logFile.delete()
395-
396-
// Clean up JVM crash dumps
397-
appDir.listFiles()?.filter { it.name.startsWith("hs_err_pid") }?.forEach { hsErr ->
398-
logger.lifecycle("[aotCache] JVM crash dump: ${hsErr.name}")
399-
// Only read text-based .log files; .mdmp files are binary minidumps
400-
// that can be hundreds of MB and would cause OOM with readText()
401-
if (hsErr.extension == "log") {
402-
logger.lifecycle(hsErr.readText().take(2000))
508+
val exitCode = process.waitFor()
509+
510+
val output = logFile.readText().takeLast(3000)
511+
if (output.isNotBlank()) {
512+
logger.lifecycle("[aotCache] Output (exit $exitCode):\n$output")
513+
}
514+
515+
// Clean up JVM crash dumps
516+
appDir.listFiles()?.filter { it.name.startsWith("hs_err_pid") }?.forEach { hsErr ->
517+
logger.lifecycle("[aotCache] JVM crash dump: ${hsErr.name}")
518+
// Only read text-based .log files; .mdmp files are binary minidumps
519+
// that can be hundreds of MB and would cause OOM with readText()
520+
if (hsErr.extension == "log") {
521+
logger.lifecycle(hsErr.readText().take(2000))
522+
}
523+
hsErr.delete()
524+
}
525+
} finally {
526+
xvfbProcess?.destroyForcibly()
527+
logFile.delete()
403528
}
404-
hsErr.delete()
529+
} finally {
530+
argFile?.delete()
405531
}
406532
}
407533

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package io.github.kdroidfilter.nucleus.desktop.application.tasks
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertThrows
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Rule
7+
import org.junit.Test
8+
import org.junit.rules.TemporaryFolder
9+
import java.io.File
10+
11+
class AotArgFileSupportTest {
12+
@get:Rule
13+
val tmpDir = TemporaryFolder()
14+
15+
@Test
16+
fun `buildAotJavaArgs preserves expected argument order`() {
17+
val aotCacheFile = tmpDir.newFile("app.aot")
18+
val args =
19+
buildAotJavaArgs(
20+
classpath = "/tmp/lib/a.jar:/tmp/lib/b.jar",
21+
javaOptions = listOf("-Xmx1g", "-Dfoo=bar"),
22+
mainClass = "com.example.Main",
23+
aotCacheFile = aotCacheFile,
24+
)
25+
26+
assertEquals("-XX:AOTCacheOutput=${aotCacheFile.absolutePath}", args[0])
27+
assertEquals("-Dnucleus.aot.mode=training", args[1])
28+
assertEquals("-cp", args[2])
29+
assertEquals("/tmp/lib/a.jar:/tmp/lib/b.jar", args[3])
30+
assertEquals("-Xmx1g", args[4])
31+
assertEquals("-Dfoo=bar", args[5])
32+
assertEquals("com.example.Main", args[6])
33+
}
34+
35+
@Test
36+
fun `escapeArgForArgFile quotes and escapes whitespace quotes and backslashes`() {
37+
val escaped = escapeArgForArgFile("-Dfoo=\"C:\\Program Files\\Nucleus\"")
38+
39+
assertEquals("\"-Dfoo=\\\"C:\\\\Program Files\\\\Nucleus\\\"\"", escaped)
40+
}
41+
42+
@Test
43+
fun `escapeArgForArgFile quotes empty string`() {
44+
assertEquals("\"\"", escapeArgForArgFile(""))
45+
}
46+
47+
@Test
48+
fun `writeJavaArgFile writes utf8 escaped args one per line`() {
49+
val file = tmpDir.newFile("nucleus-aot.args")
50+
val args =
51+
listOf(
52+
"-Xmx1g",
53+
"-cp",
54+
"C:\\Program Files\\Nucleus\\lib\\a.jar;D:\\项目\\b.jar",
55+
"-Dfoo=\"a b\"",
56+
"com.example.Main",
57+
)
58+
59+
writeJavaArgFile(file, args)
60+
61+
val lines = file.readLines(Charsets.UTF_8)
62+
assertEquals(args.size, lines.size)
63+
assertEquals("-Xmx1g", lines[0])
64+
assertEquals("-cp", lines[1])
65+
assertEquals("\"C:\\\\Program Files\\\\Nucleus\\\\lib\\\\a.jar;D:\\\\项目\\\\b.jar\"", lines[2])
66+
assertEquals("\"-Dfoo=\\\"a b\\\"\"", lines[3])
67+
assertEquals("com.example.Main", lines[4])
68+
}
69+
70+
@Test
71+
fun `writeJavaArgFile escapes windows path with backslashes but no spaces`() {
72+
val file = tmpDir.newFile("nucleus-aot-nospace.args")
73+
74+
writeJavaArgFile(file, listOf("C:\\Windows\\System32"))
75+
76+
val lines = file.readLines(Charsets.UTF_8)
77+
assertEquals(1, lines.size)
78+
assertEquals("\"C:\\\\Windows\\\\System32\"", lines[0])
79+
}
80+
81+
@Test
82+
fun `escapeArgForArgFile rejects newline characters`() {
83+
assertThrows(IllegalArgumentException::class.java) {
84+
escapeArgForArgFile("line1\nline2")
85+
}
86+
}
87+
88+
@Test
89+
fun `buildAotTempFileCandidateDirs keeps deterministic order and de-duplicates`() {
90+
val appDir = tmpDir.newFolder("app")
91+
val aotDir = tmpDir.newFolder("aot")
92+
val aotFile = File(aotDir, "app.aot")
93+
94+
val dirs =
95+
buildAotTempFileCandidateDirs(
96+
appDir = appDir,
97+
aotCacheFile = aotFile,
98+
tmpDirPath = appDir.absolutePath,
99+
)
100+
101+
assertEquals(2, dirs.size)
102+
assertEquals(appDir.absoluteFile, dirs[0])
103+
assertEquals(aotDir.absoluteFile, dirs[1])
104+
}
105+
106+
@Test
107+
fun `createAotTempFileWithFallback falls back when earlier directory is not writable`() {
108+
val writableDir = tmpDir.newFolder("writable")
109+
val impossibleDir = File(writableDir, "missing-parent/child")
110+
111+
val tempFile =
112+
createAotTempFileWithFallback(
113+
prefix = "nucleus-aot-",
114+
suffix = ".args",
115+
candidateDirs = listOf(impossibleDir, writableDir),
116+
)
117+
118+
assertTrue(tempFile.exists())
119+
assertEquals(writableDir.absoluteFile, tempFile.parentFile.absoluteFile)
120+
}
121+
}

0 commit comments

Comments
 (0)