Skip to content
Hydra Engine

The shared daemon

One JVM per user serves every client: how clients find it, the quick and long lanes, per-program locks, request labels, and what a dying client costs.

On this page

Hydra Engine runs one long-lived JVM per user. It holds Ghidra, the open projects, the analysed programs and their warm decompilers, and it answers JSON lines on a loopback socket. Agent harnesses, MCP servers and scripts are its clients. They come and go; the daemon stays.

How a client finds it

The daemon writes an endpoint file, default.json, to the state directory once it listens: port, token, pid, versions. It also holds an OS lock on default.lock for as long as it lives, so a crashed daemon's lock is released by the OS and a stale endpoint file is never dialled.

connect() and ensure read the endpoint, check that lock, and join. If there is no daemon, they take a start lock first, so two harnesses that arrive at the same moment start one daemon, not two. The daemon is started detached and outlives the process that started it.

A daemon from another install, or one that speaks another protocol, is refused with an error that names both sides. Run a second one side by side with --instance <name>.

Two lanes

Every request takes a scheduler slot before it touches a lock:

LaneCommandsSlots by default
quickdecompile, functions, list, opens, renameone per CPU
longanalyze, import, script, savea quarter of the CPUs, at least 2

Long work can never take the slots quick queries need, so one agent's whole-program analysis does not hold up another's decompile. When a lane is full, waiting requests are admitted round-robin by client: a client that sends a hundred requests cannot starve one that sends one. On a 24-thread machine, status shows the lanes like this (abridged):

{"scheduler": {"quick": {"slots": 24, "inUse": 0, "queued": 0},
               "long":  {"slots": 6,  "inUse": 0, "queued": 0}}}

One client's requests run in the order it sent them. Requests from different clients always run in parallel. A connection that carries several independent callers, as an MCP server's does, sends "parallel": true so its calls do not queue behind each other.

Locks are per program

Each program has a fair read/write lock. Queries and contained scripts share it. Edits, analysis, saves, owner scripts and mode changes take it alone. Different programs, and different projects, never wait on each other.

A request waits for its slot and its lock at most wait milliseconds (10,000 by default), then fails with busy. Nothing queues forever, and the answer names the holder. This one is real, abridged: agent-b asked for a decompile with "wait": 200 while agent-a was analysing the program:

{"code": "busy",
 "message": "/choice.exe is busy (client 4 (agent-a, claude-code) analyze); retry, or pass a longer \"wait\"",
 "details": {"program": "p2:/choice.exe", "wanted": "shared", "waitedMs": 200,
             "holder": "client 4 (agent-a, claude-code) analyze",
             "holderInfo": {"name": "agent-a", "harness": "claude-code", "command": "analyze", "heldMs": 502},
             "suggest": "retry"}}

Retry, or send a longer wait. The other refusals, not_owner and conflict, carry the same holder fields; Several writers covers them.

Labels: who is asking

hello names a connection: client is your tool, harness the agent harness running it. status, the daemon's log and every busy answer use those names.

One MCP server often serves several agents over one connection. on main · 12.3-hydra.2 Any request can carry its own client and harness, which then replace the connection's names for that request: in holder fields, in status, in the import queue, and as the scope of cancel. In Python:

a = s.labelled(client="agent-a", harness="claude-code")   # parallel by default
a.call("decompile", name="main")
a.cancel_all()                                             # only agent-a's requests

Labels tell callers apart. They are not access control: ownership stays with the connection, and anyone with the token can send any label.

When a client dies

When a connection closes, or its process dies and the OS resets the socket, the daemon cancels that client's requests, discards its unsaved changes (and logs them), and gives its programs back to the others as read-only. A client waiting for one of those programs gets it within its wait.

cancel does the same on purpose: one request by id, or all of a client's requests, running or queued.

Memory and lifetime

The heap ceiling defaults to a quarter of physical memory, between 2 and 31 GiB. A large analysed program, say 240,000 functions in a 1.5 GB project, needs a few GiB once open; status reports memory.usedMb and maxMb.

  • A read-only program nobody uses stays open for keepWarm seconds (3,600 by default), so the next client gets warm decompilers.
  • A writable project closes, and releases its lock, as soon as its last user leaves.
  • The daemon itself stays up until stop, kill, or idleTimeout seconds without clients (0, the default, means never).

Security

The socket binds loopback only. The first request on it must be hello with the 256-bit token from the endpoint file, which is owner-only. That is the whole boundary: anyone who can read your state directory can drive the daemon with your rights.

Checked against the engine on 2026-10-26. Every detail is in the README on GitHub.