Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ permissions:
contents: read

jobs:
format:
timeout-minutes: 15
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: true
- uses: coursier/cache-action@v8
with:
ignoreJob: true
- uses: VirtusLab/scala-cli-setup@v1
- run: scala-cli fmt . --check

build:
runs-on: ubuntu-latest
steps:
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ target/
project/.boot/
project/.ivy/
project/.sbtboot/

.idea/
.metals/
.vscode/
.bsp/
.bloop/
.cursor/
6 changes: 2 additions & 4 deletions agent/src/main/scala/sloth/agent/AgentConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,11 @@ object AgentConfig {

/** Parse agent config from the agentArgs string.
*
* Format: comma-separated options, e.g.:
* "verbose,include=com.example.,exclude=com.example.internal."
* Format: comma-separated options, e.g.: "verbose,include=com.example.,exclude=com.example.internal."
*
* Log level options (each sets the scribe log level):
* - verbose: Debug level
* - trace: Trace level
* (default is Warn if none specified)
* - trace: Trace level (default is Warn if none specified)
*/
def parse(agentArgs: String): AgentConfig = {
if (agentArgs == null || agentArgs.trim.isEmpty) return AgentConfig()
Expand Down
4 changes: 2 additions & 2 deletions agent/src/main/scala/sloth/agent/SlothAgent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import java.lang.instrument.Instrumentation

/** Java agent entry point for Sloth.
*
* Patches Scala 3.0-3.7 lazy val bytecode at class-load time to use
* VarHandle-based implementation (3.8+ format), avoiding sun.misc.Unsafe.
* Patches Scala 3.0-3.7 lazy val bytecode at class-load time to use VarHandle-based implementation (3.8+ format),
* avoiding sun.misc.Unsafe.
*
* Usage: java -javaagent:sloth-agent.jar[=options] -jar app.jar
*
Expand Down
30 changes: 17 additions & 13 deletions agent/src/main/scala/sloth/agent/SlothTransformer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,29 @@ import sloth.patching.BytecodePatcher

/** ClassFileTransformer that patches Scala 3.0-3.7 lazy val bytecode at load time.
*
* Uses the group-based patching API to correctly handle companion pairs where
* lazy val implementation is split across object and class files. When one side
* of a companion pair is loaded, the other side is read via getResourceAsStream
* (pure I/O, no class loading) and both are patched together. The companion's
* patched bytes are buffered for when it actually loads.
* Uses the group-based patching API to correctly handle companion pairs where lazy val implementation is split across
* object and class files. When one side of a companion pair is loaded, the other side is read via getResourceAsStream
* (pure I/O, no class loading) and both are patched together. The companion's patched bytes are buffered for when it
* actually loads.
*/
class SlothTransformer(config: AgentConfig) extends ClassFileTransformer {

/** Packages to always skip (JDK internals, our own code, Scala runtime).
* NOTE: String literals for our own package and scala/runtime/ are constructed
* via StringBuilder/Array to prevent sbt-assembly shade rules from rewriting them.
* The agent receives UNSHADED class names from the JVM.
/** Packages to always skip (JDK internals, our own code, Scala runtime). NOTE: String literals for our own package
* and scala/runtime/ are constructed via StringBuilder/Array to prevent sbt-assembly shade rules from rewriting
* them. The agent receives UNSHADED class names from the JVM.
*/
private val skipPrefixes = Array(
"java/", "javax/", "jdk/", "sun/", "com/sun/",
"java/",
"javax/",
"jdk/",
"sun/",
"com/sun/",
new StringBuilder("sl").append("oth/").toString,
new StringBuilder("sca").append("la/runtime/").toString
)

/** Buffer for patched companion bytes. When one side of a companion pair is patched,
* the other side's bytes are stored here and consumed (via remove) when that class loads.
/** Buffer for patched companion bytes. When one side of a companion pair is patched, the other side's bytes are
* stored here and consumed (via remove) when that class loads.
*/
private val patchedCompanionBuffer = new ConcurrentHashMap[String, Array[Byte]]()

Expand Down Expand Up @@ -115,7 +117,9 @@ class SlothTransformer(config: AgentConfig) extends ClassFileTransformer {
null

case Right(groups) =>
scribe.debug(s" group() returned ${groups.size} group(s): ${groups.map(g => s"${g.getClass.getSimpleName}(${g.primaryName})").mkString(", ")}")
scribe.debug(
s" group() returned ${groups.size} group(s): ${groups.map(g => s"${g.getClass.getSimpleName}(${g.primaryName})").mkString(", ")}"
)

// There should be exactly one group (single or companion pair)
groups.headOption.map(BytecodePatcher.patch(_, classLoader = Some(loader))) match {
Expand Down
49 changes: 27 additions & 22 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
inThisBuild(List(
organization := "org.virtuslab",
homepage := Some(url("https://github.com/VirtusLab/sloth")),
licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")),
developers := List(
Developer(
"lbialy",
"Łukasz Biały",
"lbialy@virtuslab.com",
url("https://github.com/VirtusLab")
inThisBuild(
List(
organization := "org.virtuslab",
homepage := Some(url("https://github.com/VirtusLab/sloth")),
licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")),
developers := List(
Developer(
"lbialy",
"Łukasz Biały",
"lbialy@virtuslab.com",
url("https://github.com/VirtusLab")
)
)
)
))
)

lazy val core = project
.in(file("core"))
Expand Down Expand Up @@ -52,7 +54,7 @@ lazy val testops = project
assembly / assemblyJarName := "sloth-testops.jar",
assembly / assemblyMergeStrategy := {
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case x => MergeStrategy.first
case x => MergeStrategy.first
}
)
.dependsOn(core)
Expand Down Expand Up @@ -107,7 +109,7 @@ lazy val cli = project
assembly / assemblyJarName := "sloth.jar",
assembly / assemblyMergeStrategy := {
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case x => MergeStrategy.first
case x => MergeStrategy.first
}
)
.dependsOn(core)
Expand All @@ -133,7 +135,7 @@ lazy val agent = project
new RuleTransformer(new RewriteRule {
override def transform(node: Node): Seq[Node] = node match {
case e: Elem if e.label == "dependencies" => NodeSeq.Empty
case n => n
case n => n
}
}).transform(node).head
},
Expand All @@ -152,17 +154,20 @@ lazy val agent = project
val fullCp = (cliJar +: depJars).map(_.getAbsolutePath).mkString(java.io.File.pathSeparator)

val debugAssembly = sys.env.contains("DEBUG_AGENT_ASSEMBLY")
val processLogger: scala.sys.process.ProcessLogger = if (debugAssembly)
scala.sys.process.ProcessLogger(s => log.info(s), s => log.error(s))
else scala.sys.process.ProcessLogger(_ => (), _ => ())
val processLogger: scala.sys.process.ProcessLogger =
if (debugAssembly)
scala.sys.process.ProcessLogger(s => log.info(s), s => log.error(s))
else scala.sys.process.ProcessLogger(_ => (), _ => ())

val processedFiles = depJars.map { depJar =>
val dest = processedDir / depJar.getName
IO.copyFile(depJar, dest)
if (debugAssembly) log.info(s"Processing ${depJar.getName}...")
val exitCode = scala.sys.process.Process(
Seq("java", "-cp", fullCp, "sloth.cli.Main", dest.getAbsolutePath)
).!(processLogger)
val exitCode = scala.sys.process
.Process(
Seq("java", "-cp", fullCp, "sloth.cli.Main", dest.getAbsolutePath)
)
.!(processLogger)
if (exitCode != 0) {
throw new MessageOnlyException(s"Failed to process ${depJar.getName} (exit code $exitCode)")
}
Expand All @@ -182,8 +187,8 @@ lazy val agent = project
assembly / assemblyJarName := "sloth-agent.jar",
assembly / assemblyMergeStrategy := {
case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case x => MergeStrategy.first
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case x => MergeStrategy.first
},
assembly / assemblyShadeRules := Seq(
ShadeRule.rename("sloth.**" -> "sloth.shaded.agent.@0").inAll,
Expand Down
6 changes: 5 additions & 1 deletion cli/src/main/scala/sloth/cli/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ object Main {
}

/** Processes a classfile group */
private def processGroup(group: ClassfileGroup, targetDir: os.Path, classLoader: ClassLoader): (String, PatchGroupResult) = {
private def processGroup(
group: ClassfileGroup,
targetDir: os.Path,
classLoader: ClassLoader
): (String, PatchGroupResult) = {
val groupName = group.primaryName
print(fansi.Color.Cyan(s"Processing: $groupName ... "))

Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/sloth/classfile/ClassfileComparator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ object ClassfileComparator {
bytes1: Option[Array[Byte]] = None,
bytes2: Option[Array[Byte]] = None
): Seq[Difference] = compare(class1, class2, bytes1, bytes2) match {
case FullyIdentical => Seq.empty
case Different(diffs) => diffs
case FullyIdentical => Seq.empty
case Different(diffs) => diffs
}
}
6 changes: 2 additions & 4 deletions core/src/main/scala/sloth/classfile/ClassfileModels.scala
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,10 @@ sealed trait ClassfileError {
object ClassfileError {

/** Error during ASM class reading. */
final case class ReadError(message: String, cause: Option[Throwable] = None)
extends ClassfileError
final case class ReadError(message: String, cause: Option[Throwable] = None) extends ClassfileError

/** Error during bytecode dump generation. */
final case class DumpError(message: String, cause: Option[Throwable] = None)
extends ClassfileError
final case class DumpError(message: String, cause: Option[Throwable] = None) extends ClassfileError

/** Invalid or corrupted classfile. */
final case class InvalidClassfile(
Expand Down
Loading
Loading