DX; Deep Debugging and my new favorite System Property

I think nobody here would disagree with me, saying that 'good Developer Experience (DX) is key to successful projects and teams'. Nice tests, reliable IDE integration and smooth CI are obvious. Sometimes, however, things get dirty: We do have complicated projects with complex environments, and I have worked in projects where debugging a broken test was almost impossible, or required, significant knowledge about the infrastructure and ceremony.

In this blogpost, I would like to share my new favourite system property, how it can be used for significantly improving DX and thank the team who invented it in IntelliJ.

And who knows, maybe other engineers will share even cooler techniques in the comments or follow-up blogposts as well? ๐Ÿ‘€

My Fault

One of the projects where I was not too happy with the DX, debugging broken tests was set-up by my very own...

Game of Thrones - Shame!

In my previous blogpost, introducing 'Poject Firework; AKA Compose Hot Reload', I proudly talked about my screenshot tests and how quickly they made me confident about the code. But did I tell you about the ceremony of debugging those tests? I mean, of course it's easy to just press 'Debug' in IntelliJ on the test's run gutter and hit a breakpoint in the code that executes the test, butโ€ฆ

Some Tests will launch new processes

** which will launch new processes, which will launch a new process, โ€ฆ

My project's main component is a javaagent, which can be attached to Compose applications, making them hot-reloadable. Integration Tests will run against different versions of Gradle, Kotlin and Compose. So typically, the @Test (or @HotReloadTest in my case) annotated method will just coordinate launches of new processes to test the entire build of a project.

This means, when I click the test-run-gutter, IntelliJ will

  1. Launch a Gradle Daemon (if necessary) | JVM #1

  2. The Gradle Daemon will launch a JVM to execute the tests within (Test Engine) | JVM #2

  3. The Test will decide which parameters (e.g. which Gradle version) to run against and then create the test project, launching a new Gradle Daemon | JVM #3

  4. The Test Gradle Daemon will then launch a test application which can be used to run the test against | JVM #4

If there are issues (e.g. a failing test, or god forbid: a bug ๐Ÿ˜ณ), the problem is mostly in the code of the 'javaagent', however, this code runs in the 4th JVM in my chain of sub-processes! While pressing 'Debug' in IntelliJ will stop breakpoints in the test infrastructure code (JVM #2), it won't affect the part I actually care about the most ๐Ÿฅบ

So, when I had to debug a given test, I had to start a separate 'Debug Server' first and instruct JVM#4 to connect to it; However, this was also not ideal: Either I made the debugger connection suspending, this would fail the test if no debug server was started before. A non-suspending connection can be added to the JVM invocation unconditionally, but then my 'premain' function might run before the debugger connection is established. Therefore, I decided to introduce a @Debug annotation that a developer should add to the test, after launching the Debug Server manually (cough, cough*: ๐Ÿ˜ฉ* Ceremony! ๐Ÿ˜ฉ*)*

There has to be a better way: My new favourite System Property!

It is clear how the ideal solution would work: Press Debug in IntelliJ and get the breakpoints in the sources you care about engaged, ready to debug!

And wait, how does the IntelliJ debugger even know about the JUnit Test Engine JVM? What kind of magic will connect IntelliJ -> Gradle -> JUnit?

The answer is the System Property I have teased:

๐ŸŽ‰ idea.debugger.dispatch.port

And I think the name is almost self-explanatory!

When running in 'Debug' mode, IntelliJ will launch a special 'Debugger Dispatch' server and inject an init-script into the build process. Before each 'JavaExec' task (tasks which will launch a new JVM) is executed, the task will connect to this 'Debugger Dispatch' socket and a new 'Debug Server' will be provisioned. Once the 'Debug Server' is started, the new JVM launches with suitable JVM args to connect to it.

And we can re-use the same mechanics to dynamically provision a debug server from IntelliJ for any other purpose! No matter where our code is running. All we need to do is: Check for the presence of the idea.debugger.dispatch.port System Property, connect to the socket and request a new Debug Server!

The protocol is straightforward: It uses java.io.DataOutputStream and expects three Strings being sent through the pipeline.

  1. The 'Debugger ID': This will always be Gradle JVM in our case

  2. The name of the process: This can be picked freely

  3. Arguments (In our case we can just supply DEBUG_SERVER_PORT=port

Here is an example function which will find a free port, connect to the 'Debugger Dispatch Server' and request a 'Debug Server' being started. Once a single byte is received in response, we can assume that the 'Debug Server' is launched and we can proceed to start a new JVM with the debugging arguments.

And just like this, all the ceremony can be brought into core test-infrastructure, and a developer can just press 'Debug' and it just works:

___

Thanks to the IntelliJ Java Build tools team who introduced the 'Debugger Dispatch Server'

1
Subscribe to my newsletter

Read articles from Sebastian Sellmair directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sebastian Sellmair
Sebastian Sellmair

Working on Kotlin at JetBrains.