Daemon Protocol
The CLI and daemon talk over a Unix domain socket using a length-prefixed protobuf framing.
Wire format
Every frame is:
[ 4-byte big-endian length ][ length bytes of protobuf payload ]There is a 32 MB cap per frame to bound malicious or malformed input.
PROTOCOL_VERSION is currently 1. The CLI sends its version in the first frame; if the daemon does not support it, the daemon returns an ErrorEvent and the CLI kills the daemon and forks a new one matching the current ktx binary.
Schema (protobuf v3)
message RequestEnvelope {
uint32 protocol_version = 1;
oneof body {
RunRequest run = 10;
ShutdownRequest shutdown = 11;
StatusRequest status = 12;
StdinChunk stdin_chunk = 13; // follows a Run frame
}
}
message ResponseEnvelope {
oneof body {
RunEvent run_event = 10;
ShutdownResponse shutdown = 11;
StatusResponse status = 12;
ErrorEvent error = 99;
}
}
message RunRequest {
string script_path = 1; // either path...
string inline_source = 2; // ...or inline source
repeated string args = 3;
string virtual_name = 4; // diagnostic file name for inline mode
string cwd = 5; // client's cwd
ResolverMode resolver_mode = 6;
string lockfile_path = 7; // FROZEN reads from, LOCK writes to
bytes stdin = 8; // legacy bytes; see StdinChunk for streaming
}
enum ResolverMode {
NORMAL = 0;
FROZEN = 1;
LOCK = 2;
}
message RunEvent {
oneof kind {
bytes stdout = 1;
bytes stderr = 2;
ExitEvent exit = 3;
}
}
message ExitEvent { int32 exit_code = 1; }
message StdinChunk {
bytes data = 1;
bool eof = 2;
}Run request flow
The daemon spawns one worker thread per connection. The worker:
- Reads the first
RequestEnvelope(theRunRequest). - Spawns a stdin-pump thread that reads subsequent
StdinChunkframes and writes them to aPipedOutputStream. - Binds the routed
System.in/out/errto this connection's pipes. - Calls
ScriptRunner.run(...). The script'sprintlnandreadLinego to the right pipes. - After the script returns, sends one final
RunEvent.exit{code}and closes.
Why per-request flock
Multiple CLI invocations may race to fork the daemon if the socket is dead. Without coordination, all of them succeed ProcessBuilder.start() and you get N daemons fighting for the socket. ktx uses an advisory file lock (.start.lock) inside the daemon directory:
Both CLIs end up using the same daemon. No races, no orphans.
Routing keys
The daemon directory is keyed by (jdkMajor, protocolVersion):
sha256("jdk=21;protocol=v1") → 5f350a9ddd42... (use first 12 hex chars)
sha256("jdk=17;protocol=v1") → 6ab41020796c...Different scripts (one wanting JDK 17, another wanting JDK 21) end up routed to different daemons, each running on the appropriate JVM. There's no inter-daemon coordination — each is a fully independent server.
The 12-hex-char prefix keeps the UDS socket path under macOS' 104-byte sun_path limit even when XDG paths are deeply nested.
Why not gRPC
- gRPC adds ~10 MB to ktx's distribution.
- gRPC requires a long-lived channel; we want short-lived per-request connections that close cleanly.
- Length-prefix + protobuf is ~30 lines of code, traceable, debuggable.
Why not unix file descriptors
The Java standard library's java.net.UnixDomainSocketAddress does not expose SCM_RIGHTS-based file-descriptor passing. We could JNI it, but:
- Gradle's daemon and Kotlin's compiler daemon both use byte-stream forwarding (no FD passing) and that has worked fine for years.
- Gateway-style stdout/stderr forwarding (sink → frame → emit) is simpler than negotiating file descriptors and is what
ktxdoes.
Versioning
When a future change is wire-incompatible (e.g. moving from length-prefix to a multi-stream multiplex), PROTOCOL_VERSION will bump to 2. CLI/daemon compare on connect; on mismatch the CLI sends ShutdownRequest to the old daemon and forks a new one. Users see at most one extra cold start.