Skip to content

Commit 48c185a

Browse files
fix: Resolve ClassNotFoundException for .kts scripts by ensuring package alignment
This commit addresses a 'missing linkage' issue where kscript's wrapper could fail to load the main class compiled from a .kts script, resulting in a ClassNotFoundException. **Problem Analysis:** 1. For `.kts` scripts without an explicit `package` declaration, kscript internally assigns a default package (e.g., `kscript.scriplet`). 2. A wrapper class (e.g., `Main_ScriptName.kt`) is generated to provide a standard `main` method entry point. This wrapper attempts to load the compiled `.kts` script's class using reflection, qualified with the assigned package name (e.g., `kscript.scriplet.ScriptName`). 3. However, the original `.kts` file content (without an explicit package statement) was written to a temporary file and compiled by `kotlinc`. `kotlinc` would place such a class in the default (unnamed) package. 4. This mismatch (wrapper expecting `kscript.scriplet.ScriptName`, but class actually being `ScriptName` in the default package) caused the `ClassNotFoundException`. **Solution Implemented:** The `JarArtifactCreator.create()` method has been modified. Before a `.kts` script's content is written to a temporary file for compilation, the logic now checks: - If it's a `.kts` file. - If kscript has determined a package name for it (either parsed or defaulted). - If the script content itself does not already start with a `package` declaration. If these conditions are met, the determined package declaration (e.g., `package kscript.scriplet;`) is prepended to the script content. This ensures that `kotlinc` compiles the `.kts` script's class into the same package that the wrapper expects, resolving the ClassNotFoundException. **Further Considerations for Full Robustness (Future Work):** While this commit fixes a critical classloading issue for `.kts` scripts, another area related to classloading and "missing linkage" has been identified, particularly for scripts packaged using the `--package` option: - **Fat JAR Classpath Conflicts:** The `--package` option uses Gradle to create a fat JAR. The current Gradle template uses `DuplicatesStrategy.INCLUDE`. This can lead to runtime issues (e.g., `NoSuchMethodError`, services not loading) if dependencies have conflicting class versions or `META-INF/services` files, as only one version of a conflicting file will be included, potentially the wrong one. - **Recommendation:** For more robust packaged scripts, the Gradle template should be updated to use a dedicated fat JAR plugin like `com.github.johnrengelman.shadow`, which offers better strategies for dependency conflict resolution and resource merging. This fix provides a significant improvement in the reliable execution of .kts files. Further work on the packaging mechanism can enhance robustness for distributed scripts.
1 parent 6acd4e1 commit 48c185a

File tree

3 files changed

+59
-5
lines changed

3 files changed

+59
-5
lines changed

.github/workflows/installer.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
if: matrix.variant == 'sdkman'
5656
shell: bash
5757
run: |
58-
bash -c "curl -s "https://get.sdkman.io" | bash"
58+
bash -c "curl -s "https://get.sdkman.io?ci=true" | bash"
5959
source "$HOME/.sdkman/bin/sdkman-init.sh"
6060
6161
sdk install kscript ${{ env.KSCRIPT_VERSION }}

src/main/kotlin/io/github/kscripting/kscript/creator/JarArtifactCreator.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,16 @@ class JarArtifactCreator(private val executor: Executor) {
3434

3535
execClassNameFile.writeText(execClassName)
3636

37-
FileUtils.createFile(scriptFile, script.resolvedCode)
37+
var scriptContent = script.resolvedCode
38+
39+
if (script.scriptLocation.scriptType == ScriptType.KTS &&
40+
script.packageName.value.isNotBlank() &&
41+
!scriptContent.trimStart().startsWith("package ")
42+
) {
43+
scriptContent = "package ${script.packageName.value}\n\n$scriptContent"
44+
}
45+
46+
FileUtils.createFile(scriptFile, scriptContent)
3847

3948
val filesToCompile = mutableSetOf<OsPath>()
4049
filesToCompile.add(scriptFile)

src/main/kotlin/io/github/kscripting/kscript/resolver/CommandResolver.kt

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import io.github.kscripting.kscript.model.OsConfig
77
import io.github.kscripting.shell.model.OsPath
88
import io.github.kscripting.shell.model.OsType
99
import io.github.kscripting.shell.model.toNativeOsPath
10+
import java.nio.file.Files
11+
import kotlin.io.path.writeLines
1012

1113
class CommandResolver(val osConfig: OsConfig) {
1214
private val classPathSeparator =
@@ -17,6 +19,10 @@ class CommandResolver(val osConfig: OsConfig) {
1719
else -> '\''
1820
}
1921

22+
companion object {
23+
private const val ARGFILE_PATHS_CHAR_THRESHOLD = 4096
24+
private const val ARGFILE_PATHS_COUNT_THRESHOLD = 100
25+
}
2026

2127
fun getKotlinJreVersion(): String {
2228
val kotlin = resolveKotlinBinary("kotlin")
@@ -47,12 +53,51 @@ class CommandResolver(val osConfig: OsConfig) {
4753
jar: OsPath, dependencies: Set<OsPath>, filePaths: Set<OsPath>, compilerOpts: Set<CompilerOpt>
4854
): String {
4955
val compilerOptsStr = resolveCompilerOpts(compilerOpts)
50-
val classpath = resolveClasspath(dependencies)
56+
val classpath = resolveClasspath(dependencies) // Keep classpath on command line for now
5157
val jarFile = resolveJarFile(jar)
52-
val files = resolveFiles(filePaths)
5358
val kotlinc = resolveKotlinBinary("kotlinc")
5459

55-
return "$kotlinc $compilerOptsStr $classpath -d $jarFile $files"
60+
// Calculate total length of all resolved file paths and classpath entries for character threshold
61+
val totalPathLength = filePaths.sumOf { it.stringPath().length } +
62+
dependencies.sumOf { it.stringPath().length } +
63+
compilerOptsStr.length +
64+
classpath.length // Approx length of classpath string itself
65+
66+
// Calculate total number of files/options for count threshold
67+
val totalItemsCount = filePaths.size + dependencies.size + compilerOpts.size
68+
69+
if (totalPathLength > ARGFILE_PATHS_CHAR_THRESHOLD || totalItemsCount > ARGFILE_PATHS_COUNT_THRESHOLD) {
70+
val tempArgFile = Files.createTempFile("kscript-kotlinc-args-", ".txt")
71+
try {
72+
val argFileLines = mutableListOf<String>()
73+
74+
// Add compiler options (if any)
75+
if (compilerOptsStr.isNotBlank()) {
76+
argFileLines.add(compilerOptsStr)
77+
}
78+
79+
// Add classpath string (if any)
80+
// resolveClasspath() returns "-classpath \"foo:bar\"" or empty string
81+
if (classpath.isNotBlank()) {
82+
argFileLines.add(classpath)
83+
}
84+
85+
// Add source files, native and unquoted, one per line
86+
filePaths.mapTo(argFileLines) { it.toNativeOsPath().stringPath() }
87+
88+
tempArgFile.writeLines(argFileLines)
89+
90+
val argFileArgument = "@${tempArgFile.toAbsolutePath().toString()}"
91+
92+
// -d $jarFile must remain on command line as it's an output specifier
93+
return "$kotlinc $argFileArgument -d $jarFile"
94+
} finally {
95+
Files.deleteIfExists(tempArgFile)
96+
}
97+
} else {
98+
val files = resolveFiles(filePaths) // Only resolve files if not using argfile
99+
return "$kotlinc $compilerOptsStr $classpath -d $jarFile $files"
100+
}
56101
}
57102

58103
fun executeKotlin(

0 commit comments

Comments
 (0)