Decision 0086
0086. A door the supervisor holds, for an engine that can only bind a port
Architecture decision record 0086: 0086. A door the supervisor holds, for an engine that can only bind a port
Status: accepted. Completes one sentence of ADR-0004, and admits one address family into one unit that ADR-0084 had shut everywhere.
Context
ADR-0004 says tacid-infer “exposes an OpenAI-compatible local socket for
any app”. It never has. What existed was a supervisor that started an engine, told it where to
listen, and left the daemon to connect to whatever the engine had bound. That worked, in the sense
that nothing had ever tried it.
Reading the two engines this machine names, at the versions it pins, showed that it could not have worked for both — and the reading is recorded in ADR-0016 because it corrects a premise there:
- llama.cpp v0.3.0 (
tools/server/server-http.cpp) treats a--hostending in.sockas anAF_UNIXpath and binds that string. It can meet the rule. - whisper.cpp v1.9.2 (
examples/server/server.cpp) callssvr->bind_to_port(hostname, port)and has noAF_UNIXpath anywhere in it. There is no argument that would make it listen on a socket.
tacid-infer runs with RestrictAddressFamilies = [ "AF_UNIX" ], IPAddressDeny = any and
PrivateNetwork = true. So an engine that can only bind a port could not start under it whatever
it was told, and the first response to that was to refuse to plan one — which is honest, and leaves
transcription and speech unbuildable for a reason that is a detail of somebody else’s main.
The rule the constraint exists to serve is not “no ports”. It is the file system decides who may reach the local mind: a path, with an owner and a mode, in a directory the daemon’s group can walk into and nothing else can. A port on the machine’s loopback would break that, because every process on the machine can reach every local port. A port in a network namespace containing one interface and one process family does not, because it is not the machine’s loopback — it is this unit’s, and it ends when the unit does.
Decision
The supervisor holds the door. Every engine is reached at /run/tacid/infer/<task>.sock,
whatever it can bind:
- an engine that binds a Unix socket is given that path as its
--host, as before; - an engine that cannot binds
127.0.0.1and a fixed port, insidetacid-infer’s own network namespace, and the supervisor accepts on the socket and copies bytes between the two.
The port is fixed rather than one the kernel picks: asking for a free port would mean binding one, reading its number, closing it and handing the number to a program that binds it again, which is a race in exchange for a problem this namespace does not have.
Fixed, and one per task — which this record first got wrong in a way worth keeping. It said the port was a single constant because “there is nothing to collide with: the namespace has one interface and one process family in it”. That is true about every other process on the machine and silent about the only case that could collide: a second engine of the same kind. Two doors on one port would both forward to whichever engine bound it first, and the second model would answer in the first one’s voice — silently, because a bridge that reaches a listening port is a bridge that works.
That case is unreachable today, and unreachable by coincidence. profile::choose picks one model
per task, and whisper.cpp is the only engine anybody has read that binds a port, so exactly one
door is ever opened; both halves are decisions taken in other files for other reasons, and the day
a Kokoro server is read and turns out to bind a port too, neither of them would still hold. So the
port is 8080 plus the task’s place in a list the bridge keeps — chat, listen, speak, embed — and
a task with no door is a compilation error rather than a collision. This is the shape
ADR-0069 is about: a rule that was
correct and rested on something it did not itself establish, so it was right about the wrong
thing.
A byte pump, not an HTTP proxy. The conversation is HTTP/1.1 on one connection. Nothing here needs to understand a header in order to move it, and a proxy that parsed one would be a second implementation of HTTP standing between the daemon and its model, with its own opinions about chunking, keep-alive and trailers. Both directions run at once, because a client may still be sending a body while the server is answering; a half-close is carried across, because an engine that reads its request to end-of-file never answers otherwise.
The unit admits AF_INET, and IPAddressAllow = "localhost" beside IPAddressDeny = any.
That pair is exactly what
ADR-0084 removed from tacidd, and the
reason it is right here and wrong there is one word: namespace. On the daemon, localhost is
every listener on the machine, so a compromised daemon could use one as a relay. Here it is one
namespace containing this process and its own children, so localhost is the port this process
just started and nothing else. PrivateNetwork = true is what makes that sentence true, and it was
already there.
An engine is planned only once somebody has read its server. llama.cpp and whisper.cpp are
read; a Kokoro server is not, and kokoro-server is a name this tree chose for a program it has
never run, so it is still refused with that as the reason. The rule earns its keep: the first
version of this passed one spelling of --host to every engine and it was right for neither.
Consequences
Transcription becomes buildable. It is not built — no model in the shipped registry can be fetched, which is ADR-0016’s deliberate state — but the reason it is not is now one reason instead of two, and the remaining one is a URL and a hash that somebody types with the file in front of them.
Every token from a local model crosses one extra hop, in userspace, inside one process. For a local engine producing tens of kilobytes a second that is not measurable, and the alternative was an engine that could not run.
The bridge is tested and the engine behind it is not, and that distinction is worth stating plainly:
what the tests cover is that bytes go both ways, that a half-close is carried, that a socket left
behind by a crash does not stop the next start, and that the door goes when the engine does. What
nothing in this tree has done is start a real whisper-server and speak to it through the door,
because no model can be fetched here. When one can, that is the test to write.
Plan now says how an engine listens rather than assuming, and tacid models reads the same
function, so a model whose engine nobody has read says so where a person is choosing rather than in
a journal afterwards.
Alternatives rejected
Leave whisper.cpp refused. Honest, and the state before this. It makes voice permanently
half-built for a reason that is a detail of another project’s main, when the machine already has
the one thing that fixes it: a network namespace nobody else is in.
Give the engine the machine’s own loopback. Then every process on the machine can reach the local mind, and the file system no longer decides anything. That is the rule this record is about.
Patch whisper.cpp to bind a socket. A fork of a C++ program that parses hostile files, carried forever, to avoid eighty lines of Rust that copy bytes.
Speak HTTP in the bridge. A second implementation of HTTP between the daemon and its model, to gain nothing: the pump does not need to know what it is carrying.
Let the daemon connect to the port directly. It would need AF_INET and a way into the
supervisor’s namespace, which is every argument in this record run backwards.