# What a Debugger Is Actually Talking To

A Julia debugger locked up mid-session over table-drawing characters in a DataFrame, which is how I ended up learning the Debug Adapter Protocol from the inside instead of the spec.


The debugger locked up the first time I inspected a DataFrame mid-session, and it took me a while to believe that the box-drawing characters in a pretty-printed table were the reason. │ ─ ⋯ …, the kind of Unicode a DataFrame uses to draw its own borders, sent over the wire as part of a debug response. The connection did not crash. It just quietly went out of sync and stopped answering, which is worse, because nothing tells you why.
That bug lives in a package called debugger/dap inside Flexible Julia, my own JetBrains plugin, and finding it is what made me actually understand the Debug Adapter Protocol instead of just implementing against the spec.
A debugger is two programs, not one The thing you click “step over” in looks like a single feature. It is not. On one side is your editor, which knows about breakpoints, source files, and a stack trace panel. On the other side is a runtime that can actually pause execution and inspect memory, in my case a Julia process running DebugAdapter.jl. DAP is the protocol in between: a JSON message format, sent over a socket or a process’s stdio, that lets those two sides agree on a shared vocabulary without either one knowing anything about the other’s internals.
The conversation follows a fixed shape. The client sends initialize and gets back the server’s capabilities. It sends setBreakpoints for whichever file the user is editing. It sends launch, and once the server confirms it is configured, execution actually starts. Then the client waits. When the runtime hits a breakpoint, throws an uncaught exception, or finishes a step command, the server pushes a stopped event, unprompted, and only then does the client ask the follow-up questions: stackTrace for where you are, scopes and variables for what’s in scope, evaluate if the user typed something into a watch expression.
Why the protocol existing at all is the point Before something like this existed, “support debugging in Java, and Python, and Julia” meant N editors each building a bespoke integration against M different runtimes, which is N times M pieces of one-off code, every one of them different, every one of them somebody’s private knowledge of how a specific debugger’s wire format works. DAP turns that into N plus M: write one client that speaks the protocol, and it works against any debug adapter that also speaks it, whether that adapter fronts Julia, or something else entirely. The protocol is boring on purpose. Boring is what lets it be reused.
What actually goes wrong when you build the client None of that boredom survives contact with the byte level. My DapMessageParser reads a Content-Length header, in bytes, then has to read exactly that many bytes of body before decoding it as UTF-8. The first version I wrote read the body a character at a time instead, which works fine for plain ASCII output and silently desynchronizes the stream the moment a multi-byte UTF-8 character shows up, because a “character” and a “byte” stop being the same count the instant you’re not looking at plain English text anymore. A DataFrame’s borders were exactly that case, and by the time the mismatch showed up, the framing was already wrong for every message after it. The fix was to stop reading characters at all and read the body as raw bytes, counted, then decode once at the end.
The ordering isn’t always honest either. DebugAdapter.jl defers its response to launch until after configurationDone arrives, which means a client that naively waits for launch’s response before sending anything else will simply hang forever, waiting on a reply that is not coming yet. My client sends launch fire-and-forget, no blocking wait, and treats the eventual stopped or initialized event as the real signal that things are underway. The spec allows this kind of deferral; it does not warn you that you’ll discover it by hanging.
And because the server on the other end is a real Julia process bound to a real socket, killing the debug session is its own small chore. Forcibly destroying the parent process on Windows does not touch its children, so a stale DebugAdapter.jl process can sit there holding the port from a session you already ended, ready to make the next “Debug” click fail for a reason that has nothing to do with your code. My launcher walks the process tree and kills descendants explicitly before it kills the parent, and separately sweeps for anything still tagged DebugAdapter or FlexibleJuliaDebug at startup, because leftover Julia processes from a crashed run are common enough to plan for rather than just hope against.
None of this is in the protocol document, and it shouldn’t be. DAP’s job is to standardize the shape of the conversation, not to protect you from a byte-counting mistake in your own transport code. That part is still yours to get wrong first and fix second, and I did, in that order, on a DataFrame of all things.
