Skip to content

semcode-mcp: report readiness over sd_notify - #11

Open
brauner wants to merge 3 commits into
masoncl:mainfrom
brauner:mcp-sd-notify
Open

brauner wants to merge 3 commits into
masoncl:mainfrom
brauner:mcp-sd-notify

Conversation

@brauner

@brauner brauner commented Nov 6, 2026

Copy link
Copy Markdown

semcode-mcp answers requests right away, but lookups only work once the
startup pass has checked that the index covers the current commit, or has
indexed it. Until then every lookup gets "currently being indexed, try
again", even when the check finds nothing to do, and whoever started the
server has no way to tell when it is ready other than asking. The systemd
CI, which starts a server per review agent, grew a probe for exactly that.

Two commits on top of #10:

  • speak sd_notify(3) when NOTIFY_SOCKET is set: STATUS= for the phases,
    READY=1 once lookups are answered, STOPPING=1 on the way out, no READY=1
    after a failed check. No new dependency, it is a datagram to the socket,
    abstract form included. Daan De Meyer suggested this.
  • let lookups wait for the startup pass, up to thirty seconds, instead of
    being sent away while it only checks. A real indexing run keeps the old
    answer after that.

Tested with a listener on the socket: READY=1 after 0.1 s on a systemd
index, 0.2 s on a checkout whose top commit touches no C file (the case
that scans the whole index), 0.9 s on a kernel index; an empty index
reports the failed check and never says ready. A lookup fired at startup
on that checkout now waits about a second and answers instead of being
refused.

Note that systemd-notify --fork is not a way to probe a stdio server: it
wires the child's stdin to /dev/null, so the server quits at EOF, and
exits 0 on a clean child exit. A small NOTIFY_SOCKET listener is.

The MCP server reads one request from stdin, awaits handle_request()
for it and only then reads the next one. So a query that scans the
whole database, grep_functions over every function body or
find_implementors, holds up every other client of the server until it
is done, and they all run into the idle timeout of the MCP client at
the same time. Twenty review agents on one server stalled for the
better part of an hour behind a few such scans, while the CLI, which
runs the same query code in a process of its own, kept answering in
under a second.

So hand each request to a task of its own and let the responses go
out as they finish. JSON-RPC matches them by id, the order doesn't
matter, and the stdout lock already serializes the writes with the
notification task. The handlers borrow their arguments across awaits
in a way rustc can't prove Send for, so the task drives the request on
a thread of the blocking pool with Handle::block_on() instead of
running it on a worker. That also keeps a CPU bound scan off the
runtime's workers. The number of requests in flight is capped at the
number of CPUs with a semaphore, so that any number of scans can't
thrash the machine. Notifications are still handled inline, there is
nothing to wait for there. A task that can't write its response gives
up on it and logs that, the loop ends on its own when stdin is closed.

A find_function sent right after a grep_functions over all function
bodies of a kernel tree used to be answered after the scan, 26 seconds
in. Now it comes back in 4 seconds while the scan is still running.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
The server starts answering requests right away, but lookups only work
once the startup pass has checked that the index covers the current
commit, or has indexed it. Whoever starts the server has no way to tell
when that is, other than asking and being told to try again. The systemd
CI grew a probe for exactly that.

So speak the sd_notify(3) protocol when NOTIFY_SOCKET is set: STATUS=
lines for the phases, READY=1 once lookups are answered, STOPPING=1 on
the way out. systemd-notify --fork then returns when the server is
ready, and a Type=notify unit becomes active at that point. A failed
check or indexing run sends a STATUS= line and no READY=1. No new
dependency, it is a datagram to the socket, with the abstract form
handled.

Suggested-by: Daan De Meyer <daan@amutable.com>
Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
Until the startup pass is done every lookup is answered with "currently
being indexed, try again", even when the check finds nothing to do,
which takes a second or so. A client that starts the server and asks
right away, as an agent does, gets a refusal and has to know to retry.

So let lookups wait for the startup pass, up to thirty seconds, and only
then say that indexing is still running. The check itself is over long
before that. A real indexing run of a stale index is not, and keeps the
old answer.

Signed-off-by: Christian Brauner (Amutable) <brauner@kernel.org>
brauner added a commit to brauner/systemd that referenced this pull request Nov 6, 2026
The probe hands the server its four requests, keeps the pipe open for
three seconds and then reads whatever came back. That is too little in
two ways. The first lookup on the kernel index takes a few seconds
right after the index was restored, so the pipe closes before the
answer arrives. And when the top commit of the pull request touches no
C file, semcode-mcp cannot tell from the diff that its index is current
and scans the whole index at startup, answering the lookup with "being
indexed, try again" for about a second. Either way a working server is
left out of the review as if it were broken, which happened to the
kernel server on one review and to the checkout's server on the next.

Instead of guessing from the outside when the server is ready, let it
tell us. semcode-mcp now implements the sd_notify protocol (cf. [1]):
it reports STATUS= while it checks the index against the current
commit and READY=1 once the check is through and lookups are answered,
and a lookup that arrives during the check waits for it instead of
being refused. The pin moves to the fork commit that has this.

The probe becomes a small Python script that does what systemd-notify
--fork would do: bind a socket, start the wrapper with NOTIFY_SOCKET
pointing at it and wait for READY=1, for up to thirty seconds. We
can't use systemd-notify itself. The runner has systemd 255 and --fork
came with 258, and it would connect the server's stdin to /dev/null,
which ends a stdio server right away. The socket lives next to the
wrapper because the sandbox mounts a tmpfs over /tmp and /run and
would not see it there. The probe accepts READY=1 only together with
a status that says the index is up to date, so a server that comes up
with a stale index is left out as well, and a failed check or an
exiting server fails the probe at once.

The wrapper hands NOTIFY_SOCKET into the sandbox only when it is set.
Claude Code sets none, so the servers it starts notify nobody.

The script lives next to the workflow, like the build and unit test
scripts do, instead of in a heredoc inside it. The review takes it from
the base revision the same way it takes AGENTS.md, never from the
checkout, which is the pull request's.

[1]: masoncl/semcode-devel#11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant