Cross-process Lambdas

Introduction

I’ve been working to invent a black-box integration test framework for our plugin which does the following:

  • creates an environment in the first JVM
  • starts the second JVM process with the plugin
  • makes some action inside the started JVM
  • checks the expected effects from outside the application

Essentially, my problem was — how to create a lambda in one JVM and execute it in the other JVM. In the post, I discuss approaches to create integration tests like that:

@Test
fun exampleTest() {
  setupEnvironmentForTestion()
  startProcessAndRun {
    weDoThatInTheExternalProcess()
    toMakeTestScenario()
  }
  checkExpectations()  
}

Ideally, the solution should allow using captured variables between processes too. For example:

@Test
fun exampleTest() {
  val capturedVariable = setupEnvironmentForTestion()
  startProcessAndRun {
    weDoThatInTheExternalProcess(capturedVariable)
    toMakeTestScenario()
  }
  checkExpectations()  
}

In this post, we’ll explore several ways to create a lambda in one JVM and execute it in the other JVM processes.

Whole Class Approach

The very first approach could be to have the whole exampleTest function execute in the second JVM. We may need to replace setupEnvironmentForTestion() and checkExpectations() implementations on the second JVM with empty stubs for that. It is hard to pass data between processes. Finally, a code may look like:

@Test
fun exampleTest() {
  runInHost {
    setupEnvironmentForTestion()
  }
  startProcessAndRun {
    weDoThatInTheExternalProcess()
    toMakeTestScenario()
  }
  runInHost {
    checkExpectations()
  }  
}

It is a powerful approach from one side, but it is more constrained. It is quite hard to pass parameters between processes, for example.

So far, that scenario is hard to apply for my use-case. Let’s cover more lambda-specific approaches.

Dealing with Lambdas

I’ve a set of test cases to see how lambdas behave in different contexts. Each test has two examples just like we’ve had above:

  • a lambda with no captured parameters
  • a lambda with a captured local variable

This pattern is repeated many times in Java and in Kotlin to try different approaches to the lambda:

  • lambda implementing Runnable (Java and Kotlin)
  • lambda implementing custom interface (Java and Kotlin)
  • Kotlin lambda for a Kotlin functional type
  • Kotlin inline functions with inline lambda

In addition to that, we try wrapping a lambda with a higher order function which returns another lambda.

Grab the project from GitHub and give it a try. We are getting to analyze the results of it

Using Lambda Constructors

The very first approach is to check if a generated lambda class has a default constructor. We could use the constructor to create an instance of that lambda in the second JVM process.

Test Run Result: Lambda constructors

Lambdas in Kotlin and in Java do not have default constructors, that is hot hard to check via an example application. Apparently, there is a trick that works well. We can implicitly create a class and an object for every lambda via Kotlin Inline functions.

The following code makes Kotlin compiler generate a default constructor for an inlined lambda:

inline fun lambdaToClass(crossinline action: () -> Unit) {
  val holder = object : Runnable {
    override fun run() {
      action()
    }
  }
  doSomethingWithTheClass(holder.javaClass)
}

Each call of the function lambdaToClass is inlined by the compiler, so we will have a dedicated class for each function call site. The lambda, which we pass as a parameter, is inlined into the generated anonymous class. We are free to make the anonymous class, extend a specific class or implement some interfaces.

NOTE. This approach is based on the side effects of Kotlin compiler. Future versions of Kotlin compiler may behave differently and break that solution.

The generated anonymous class has a default contractor with no parameters, when there are no captured variables. The constructor will have more parameters for captured variables. So far, that solution works, but it is not flexible enough. Let’s see if serialization can be used instead.

Using Lambda Serialization

For this series of experiments, we will be using JDK’s standard ObjectInputStream and ObjectOutputStream to serialize a lambda in one process and to load the serialized one on the other JVM process. The following code is used to save and load the lambda:

val bytes = ByteArrayOutputStream().use { bos ->
  ObjectOutputStream(bos).use { it.writeObject(obj) }
  bos.toByteArray()
}

val reloaded = bytes.inputStream().use { bis ->
  ObjectInputStream(bis).readObject()
}

Now it’s time to run all the experiments and see the outcomes:

Test Run Result: Lambda constructors

We see the following from the tests:

  • Java’s lambdas are serializable when the respective functional interface implements java.io.Serializable
  • Kotlin (from 1.5) generates serializable lambdas for Java serializable functional interfaces
  • Kotlin’s lambdas for Kotlin functional types (e.g. () -> Unit) are implicitly serializable
  • Kotlin’s inlined lambdas can be made serializable if we inline them into a serializable object declaration

In Java, we can make a lambda be Serializable by extending a java.io.Serializable from a functional interface that we use. The same works both in Java and in Kotlin:

interface SerializableRunnable extends Serializable, Runnable { } 

SerializableRunnable serializableLambda = () -> { };

In Kotlin, we can just create a lambda, and it will be serializable:

val serializableLambda = { }

In addition to that, we may apply the same trick with inline function as above, but we will not be using the anonymous class directly:

inline fun lambdaToClass(crossinline action: () -> Unit) {
  val holder = object : Runnable, Serializable {
    override fun run() {
      action()
    }
  }
  serializeMe(holder)
}

There are several more interesting fasts:

  • Lambda in Java are implemented via invokedynamic and JDK’s LambdaMetafactory whose class names are like Simple$$Lambda$55/0x0000000800180040
  • Deserialized Java lambda may have another type name
  • Lambdas for Kotlin functional types implicitly implement Serializable and are now complied as ordinary classes

Starting from Kotlin 1.5, the compiler uses the invokedynamic and JDK’s LambdaMetafactory to implement lambdas for Java-declared functional interfaces and fun interface Kotlin declarations. Future versions will use invokedynamic all lambdas.

We see from the experiments that the easiest way to pass a lambda between JVM processes is to use serialization.

The Classpath

Running an arbitrary code in the second JVM process is easy if we have the same classpath.

My scenario was different, I was only able to execute a Groovy script in the second JVM Process. The classpath was totally unrelated, and I had to start with classloading. We generate a Groovy script with all necessary parameters inlined. It includes:

  • classpath of the first process
  • serialized lambda as Base64
  • helper entrypoint classname

Putting everything together, I’ve got:

URL[] cp = [
  ///INSTERT CLASSPATH HERE
  /// new File("<PATH GOES HERE>").toURI().toURL(),
]
classBase64State = '<PUT SERIALIZED STATE HERE>'
class X{}
cl = new URLClassLoader(cp, X.class.classLoader)
clazz = cl.loadClass('OurEntryPointClass')
loader = clazz.newInstance()
loader.execute(classBase64State)

We use yet another class named OurEntryPointClass to move as much code as possible from the Groovy script. The classloader, which we use to load our classes from the first JVM, delegates to the parent loader first, so we have to be careful with different versions of the same libraries that we use in both processes.

class OurEntryPointClass { 
  fun execute(text: String) {
    val oldLoader = Thread.currentThread().contextClassLoader
    Thread.currentThread().contextClassLoader = javaClass.classLoader
    try {
      val loadedLambda = Base64.getDecoder()
        .decode(text).inputStream()
        .let(::ObjectInputStream)
        .readObject() as? Runnable
        ?: error("Failed to load Consumer<Project> from the lambda")
      loadedLambda.run()
    } finally {
      Thread.currentThread().contextClassLoader = oldLoader
    }
  }

Conclusion

Passing a lambda between JVM processes is not hard. There are multiple ways to solve that goal. Serialization works greatly for that! I hope my examples will help you to solve your problem in the future. I’d be grateful to know your use-cases too.

comments powered by Disqus