@@ -22,11 +22,113 @@ import org.gradle.api.tasks.PathSensitivity
2222import org.gradle.api.tasks.TaskAction
2323import org.gradle.work.DisableCachingByDefault
2424import java.io.File
25+ import java.io.IOException
2526
2627private const val AOT_CACHE_FILENAME = " app.aot"
2728private const val MIN_AOT_JDK_VERSION = 25
2829private 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
0 commit comments