Skip to content

Add sloth support#4348

Open
Gedochao wants to merge 19 commits into
VirtusLab:mainfrom
Gedochao:feature/lazyvalgrade
Open

Add sloth support#4348
Gedochao wants to merge 19 commits into
VirtusLab:mainfrom
Gedochao:feature/lazyvalgrade

Conversation

@Gedochao

@Gedochao Gedochao commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Adds Sloth support behind --sloth / //> using sloth
https://github.com/VirtusLab/sloth

Scala 3, up till 3.7.x series, has been using the (now terminally deprecated) sun.misc.Unsafe API. This in turn results in some ugly warnings on JDK 24+ (and likely errors in the future).

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by scala.runtime.LazyVals$ 
(file:(...)/maven2/org/scala-lang/scala3-library_3/3.7.4/scala3-library_3-3.7.4.jar)
WARNING: Please consider reporting this to the maintainers of class scala.runtime.LazyVals$
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release

Now, while Scala 3.8+ uses a new implementation, and Scala 3.3 allows for the new implementation on JDK 9+ behind the -Yfuture-lazy-vals compiler flag, the problem is not entirely solved. Even if your app is on the latest Scala, it likely still depends on libraries built with Scala 3.3 (or some other <3.8 version). That in turn brings the problem back. And since it is not widespread to suffix Scala 3 libraries with the minor, as we did in Scala 2 days, it is not entirely easy to spot which libraries will pose a problem.

Sloth is a bytecode patcher, which is capable of patching all past lazy val implementations (including the pre-3.3 one) up to the current one. Kudos to @lbialy for developing it.

Behind the --sloth flag, this PR allows to post-process and patch the bytecode of your app/artifacts to silently get rid of the problem.
The --sloth-agent flag runs Sloth as a Java agent instead, allowing it to i.e. run in the JVM your tests live.
More details on how it works and what it does at https://github.com/VirtusLab/sloth

This feature is brought to the following sub-commands:

  • run
  • compile
  • doc
  • fix
  • package
  • publish
  • repl (including the REPL artifacts themselves for Scala <3.8)
  • run
  • test

Checklist

  • tested the solution locally and it works
  • ran the code formatter (scala-cli fmt .)
  • ran scalafix (./mill -i __.fix)
  • ran reference docs auto-generation (./mill -i 'generate-reference-doc[]'.run)

How much have your relied on LLM-based tools in this contribution?

extensively, Cursor + Claude

How was the solution tested?

./mill -i integration.test.jvm '*sun.misc.Unsafe*'

@Gedochao

Copy link
Copy Markdown
Contributor Author

@lbialy how close are we to publishing a stable version of lazyvalgrade to Maven Central?

@Gedochao
Gedochao force-pushed the feature/lazyvalgrade branch from a8e0c42 to 52da2af Compare June 30, 2026 08:38
@SethTisue

Copy link
Copy Markdown
Contributor

Why is it called "lazyvalgrade", what does "grade" mean here?

@Gedochao

Gedochao commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@SethTisue I believe that's a pun on "lazy val upgrade".
We've already been discussing releasing it under a different name, coming very soon 😅

@Gedochao
Gedochao force-pushed the feature/lazyvalgrade branch from 52da2af to 330d1d0 Compare July 2, 2026 14:03
@Gedochao Gedochao changed the title [WIP] Add lazyvalgrade support [WIP] Add sloth support Jul 2, 2026
@Gedochao

Gedochao commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

https://central.sonatype.com/search?q=org.virtuslab+sloth
Sloth artifacts are up!

Comment thread website/docs/reference/cli-options.md
@tgodzik

tgodzik commented Jul 3, 2026

Copy link
Copy Markdown
Member

Do we now have adopt a sloth in a zoo? And have sloth plushies?

@Gedochao

Gedochao commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

We should. 🦥

@Gedochao
Gedochao force-pushed the feature/lazyvalgrade branch 2 times, most recently from bbac081 to 15645e2 Compare July 14, 2026 10:19
@Gedochao
Gedochao force-pushed the feature/lazyvalgrade branch from b243a04 to 714f8a5 Compare July 21, 2026 10:11
@Gedochao
Gedochao force-pushed the feature/lazyvalgrade branch 2 times, most recently from cb6b21a to e1317a2 Compare July 22, 2026 08:18
@Gedochao
Gedochao force-pushed the feature/lazyvalgrade branch from e1317a2 to cd093e4 Compare July 22, 2026 14:19
@Gedochao Gedochao changed the title [WIP] Add sloth support Add sloth support Jul 22, 2026
@Gedochao
Gedochao marked this pull request as ready for review July 22, 2026 14:49

@warcholjakub warcholjakub left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are the issues I managed to spot, while experimenting with it in the terminal. I'll look at the code next.

Comment on lines +1081 to +1082
val patchedDest = value(SlothPatcher.patchJarFile(destPath, options, logger))
if patchedDest != destPath then os.copy.over(patchedDest, destPath, createFolders = true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure whether this behaviour is intentional, but even if it is, we should probably warn the user.

With preamble:

jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> cat Main.scala
//> using scala 3.3.8

object Greetings:
  lazy val uno: String = "Heloł"
  lazy val dos: String = "Greetings"

@main def run(): Unit =
  import Greetings.*
  println(s"$uno & $dos")
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> ./mill -i scala --power package --assembly --sloth Main.scala -o app.jar
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> java -jar app.jar
Error: Invalid or corrupt jarfile app.jar

Without preamble:

jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade) [1]> ./mill -i scala --power package --assembly --preamble=false --sloth Main.scala -o app2.jar
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> java -jar app2.jar
Heloł & Greetings

Comment on lines +77 to +79
private def patchIfJar(path: os.Path, logger: Logger): os.Path =
if path.ext == "jar" then patchJar(path, logger)
else path

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we allow package outputs without a .jar extension, we shouldn't rely solely on the extension to determine whether it's a jar. At the very least, we should warn the user when Sloth does not patch an artifact.

jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> ./mill -i scala --power package --assembly --force --sloth Main.scala -o app_no_jar_ext

jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> java -jar app_no_jar_ext
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by scala.runtime.LazyVals$ (file:/Users/jwarchol/Documents/GitHub/scala-cli/app_no_jar)
WARNING: Please consider reporting this to the maintainers of class scala.runtime.LazyVals$
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
Heloł & Greetings

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: I've noticed that //> using sloth false results in Sloth being enabled. I think it'd be a better UX, if we handled true & false or informed user, that it doesn't take arguments.

@warcholjakub

warcholjakub commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

I'm wondering about one thing. Let's assume this hypothetical scenario:

  1. Person 1 makes a library, and signs it.
  2. Person 2 wants to use this library in his app. They also use sloth.
  3. Sloth modifies the library, hence the hashes don't match up.
  4. Person 2 runs his app, and verification fails.

Tried to reproduce it, and it indeed failed, but not sure how realistic and common this problem would be. Also I don't really see any straightforward solution. But yeah - just something to think about.

EDIT: --sloth-agent seems to avoid this issue

@warcholjakub warcholjakub left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final comments

@@ -97,8 +100,11 @@ object NativeImage {
)

if cacheData.changed then {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: cacheData doesn't include the fact whether someone used sloth. So if you build an image without sloth, and then try with it enabled, it won't rebuild the image.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oooh, this is a good catch

Comment on lines +117 to +129
Using.resource(JarOutputStream(os.write.outputStream(dest), manifest)): jos =>
for
path <- os.walk(dir)
if os.isFile(path)
do
val relativePath = path.relativeTo(dir).toString.replace('\\', '/')
val entry = ZipEntry(relativePath)
entry.setLastModifiedTime(FileTime.fromMillis(os.mtime(path)))
val content = os.read.bytes(path)
entry.setSize(content.length)
jos.putNextEntry(entry)
jos.write(content)
jos.closeEntry()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We create a manifest in line 117, but if the user has supplied their own, we later try to add it, which causes a duplication error.

@lbialy

lbialy commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@warcholjakub Can you elaborate about what do you mean by sloth modifying the dependency library? In what circumstances the verification you mentioned happens? What exactly fails verification?

@warcholjakub

warcholjakub commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

@warcholjakub Can you elaborate about what do you mean by sloth modifying the dependency library? In what circumstances the verification you mentioned happens? What exactly fails verification?

I mean something like this:

jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> cat SignedLib.scala
//> using scala 3.3.8
package signedlib
object SignedLib:
  lazy val message = "hello from signed library"
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> cat Main.scala 
import signedlib.SignedLib
@main def main(): Unit =
  println(SignedLib.message)
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> ./mill -i scala --power package SignedLib.scala --library  -o "signed.jar"
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> jarsigner -keystore "key.p12" -storepass changeit "signed.jar" test
jar signed.
jwarchol@vl-d-1635 ~/D/G/scala-cli (feature/lazyvalgrade)> ./mill -i scala --power run Main.scala --sloth --classpath signed.jar
[...]
Exception in thread "main" java.lang.SecurityException: SHA-384 digest error for Greetings$.class
        at java.base/sun.security.util.ManifestEntryVerifier.verify(ManifestEntryVerifier.java:254)
        at java.base/java.util.jar.JarVerifier.processEntry(JarVerifier.java:248)
        at java.base/java.util.jar.JarVerifier.update(JarVerifier.java:235)
        at java.base/java.util.jar.JarVerifier$VerifierStream.read(JarVerifier.java:453)
        at java.base/jdk.internal.loader.Resource.getBytes(Resource.java:106)
        at java.base/jdk.internal.loader.URLClassPath$JarLoader$1.getBytes(URLClassPath.java:745)
        at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:773)
        at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:691)
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:620)
        at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:578)
        at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:490)
        at Main$package$.run(Main.scala:9)
        at run.main(Main.scala:7)

As I said, I'm not sure whether it's a realistic scenario - just smth I noticed.

EDIT: Modified the scenario a bit.

@lbialy

lbialy commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

I did some research and you're right that the problem exists but fortunately:

a) signing is now quite rare

b) the whole signing mechanic is contingent on the presence of

META-INF/*.SF
META-INF/*.DSA
META-INF/*.RSA
META-INF/*.EC
META-INF/SIG-*

files.

@Gedochao would it be possible to include a step where deps are copied to .scala-build, have these files stripped and then used as the runtime/package classpath when --sloth is specified?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants