Skip to content

HOMI: replace the shared segment with a socket (1/4) - #2

Closed
safl wants to merge 14 commits into
upcie_registry_vendoredfrom
homi_rpc
Closed

HOMI: replace the shared segment with a socket (1/4)#2
safl wants to merge 14 commits into
upcie_registry_vendoredfrom
homi_rpc

Conversation

@safl

@safl safl commented Aug 25, 2026

Copy link
Copy Markdown
Owner

HOMI's multi-process mode shares a controller by naming a POSIX shared-memory
segment and having consumers map it. That cannot work behind an IOMMU: the vfio
device file is exclusive, so a second process can open it but not bind it, and
no amount of shared memory hands over the binding. The descriptor itself has to
cross, which needs a channel rather than a segment.

So the owner serves consumers over a unix socket, passing the vfio device, the
iommufd and the heap as descriptors, and answers admin and queue requests on the
same channel. The segment goes: xnvme_be_upcie_mproc.c loses 806 lines.

Stacked on the uPCIe delegation layer, vendored from safl/upcie#65.

Worth a careful look

That every commit leaves the tree working. The runtime is built up over
eight commits before the segment is removed, so the removal is a deletion rather
than a rewrite. homi status is new here rather than ported; it did not exist
before this branch.

The socket is the whole security boundary. A consumer that reaches the
socket receives the device descriptor, so anything it could be stopped from
doing afterwards it can already do. The admin hook accepts everything on
purpose; it is a place for later policy, not a whitelist.

What happens when somebody is already serving. Taking the socket, refusing a
stranger's protocol version, and reaping a consumer that disconnected are the
paths worth reading closely.

Verification

Every commit builds release individually on Linux 7.0.0-28-generic, and the tip
builds release, debug and with -Dbe_upcie=false. make format-all is clean.

Not yet re-run against this cut: the cijoe test_mproc selection and the
test_homi suite need a controller, and were last exercised against an earlier
arrangement of these commits. The GPU-under-vfio paths are in a later PR in this
stack and are not exercised here at all.

safl added 14 commits August 25, 2026 22:55
…s usage

The usage output is captured from the tool itself rather than transcribed, so
it cannot drift from what `--help` actually prints.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
uPCIe grew the primitives needed to share a controller under vfio: a runtime
record describing a controller in offsets, a delegation protocol, and a heap
that carries its own description. None of them exist at v0.7.0, which is what
this tree vendored until now, so the whole tree moves to v0.8.0 rather than
mixing versions. Taken verbatim from safl/upcie#65, which tags the layer
safl/upcie#63 adds.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
…attach to

Sharing a controller under an IOMMU means handing a consumer descriptors, since
the device file cannot be bound twice, and descriptors alone are not enough: the
consumer still has to know where the controller's queues and buffers live inside
the heap it has been given. So the owner publishes a record of that, in data and
heap offsets rather than addresses, which mean nothing in another address space.

What backs the heap decides what is published. Under an IOMMU the heap is
described in IOVAs and its descriptor is the dmamem's, while on the UIO path it
is physical addresses and the hugepage's; publishing the wrong one hands a
consumer a file it cannot map, or addresses its device will not honour.

The record and the heap's description are allocated per export, so the export
carries their offsets and releases them on every path out, including the ones
that fail after allocating. xnvme_mproc_serve() returns so the caller can close
its devices, so a process serving twice would otherwise leave one of each
behind per call.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
A process that wants to use a controller somebody else opened has to be handed
descriptors, and a unix socket is the only way Linux moves a descriptor between
unrelated processes. So the owner holds one and answers what arrives on it,
which also settles the questions the shared segment answered badly: binding the
address is the election, and a closed connection is how a consumer's death is
noticed, whatever it was doing when it died.

The listener is non-blocking because accept() is drained in a loop, while what
it hands back is not, since reading a message waits for the rest of one. A
consumer that connects and then takes a moment to write has not gone away.

Serving reads this backend's own state, so a device opened through another
backend is refused rather than reinterpreted.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
The heap's allocator stays with homi, so a consumer has nowhere to get a DMA
buffer, and every I/O needs one. It asks instead, and homi allocates on its
behalf and answers with an offset the consumer's own mapping resolves.

What that adds to the serving side is bookkeeping rather than mechanism: a
consumer that disconnects has its loans reclaimed along with its queues, and in
that order, so the controller loses the ability to reach an address before the
address stops meaning anything.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
The counterpart to serving. Where the owner allocated the memory and opened the
controller, this receives descriptors for both and builds a view: the same
memory mapped here, a translation table for this process's addresses, and BAR0
so that queues granted later can be rung from this side.

Nothing is allocated and nothing is owned, which is the point. The physical
addresses come from where the owner left them, because reading them here would
need a privilege a consumer has no reason to hold, and the socket staying open
is what tells the owner this process is still using what it was given.

Connecting to nobody is not an error: it means no one is serving that
identifier, and the caller decides whether to become the owner instead.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
Opening with an shm_id now tries to attach before it builds anything. Somebody
may already own that identifier, and attaching to them is cheaper than
allocating a runtime and then discovering they exist. Finding nobody is how a
process learns it should become the owner, which is what the shared segment's
lock used to say.

Buffers follow the same rule as the admin queue: an attached process asks for
memory rather than taking it, since the allocator belongs to whoever owns the
heap. Teardown closes the socket and returns, because nothing below that is this
process's to release, and the close is what tells the owner to reclaim it.

The restriction to uio_pci_generic goes with it. It was there because the owner
published a hugepage by path for consumers to re-open, which the memfd and
container paths could not do. What crosses now is descriptors, and a descriptor
is a descriptor whichever way the controller is reached.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
… published

An attached process describes the controller rather than opening it, because
opening one twice is what the first measurement showed cannot be done. It fills
a local structure from the record and points it at its own mapping of BAR0, so
that a queue granted later is rung from here rather than through anybody.

Queues come the same way. Asking produces identifiers and offsets; this side
turns them into addresses, derives the doorbells from its own mapping, and
builds the request pool around the scratch the grant named, since a process that
cannot allocate from the heap cannot make that scratch for itself.

The admin queue is left empty on purpose. There is one of it, it belongs to the
owner, and leaving a pointer to something no process here may drive would be
worse than leaving nothing.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
With the pieces in place, opening a device while attached takes the short path:
describe the controller from the record, ask for a queue to submit on, and skip
everything that assumes this process opened anything. Bus Master is left alone
for the same reason a shared-segment secondary leaves it alone, since the owner
flipped it and config space is not this process's to touch.

Closing hands the queue back and frees the description. The controller is not
closed, because it is not this process's to close, and the BAR mapping goes with
the runtime rather than with the device.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
An attached process has no admin queue, deliberately: there is one of it and it
belongs to whoever opened the controller. So the command goes over the socket
and the owner submits it.

The payload does not go with it. The command names an address this process
registered or was granted, so an identify lands in this process's own buffer and
only the command and its completion cross. Where a transfer needs a PRP list,
the scratch comes from the granted queue's pool, since that is the only scratch
a process which cannot allocate has.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
An attached process cannot create a queue: creating one means an admin command
on a queue it does not have, and memory from a heap it cannot allocate from. It
asks instead, and turns the offsets it is given into addresses and doorbells of
its own.

Handing the queue back goes the same way, so a consumer that closes cleanly
returns what it took, and one that does not is reaped by the owner when the
socket closes.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
The segment answered three questions and answered them badly. Who is primary
was decided by an flock on a role file; whether a runtime is alive was inferred
from a refcount a killed process never decremented, which is why stale-segment
detection had to exist; and where a controller's objects live was published as
the primary's own pointers, which every consumer then rebased by the offset
between two mappings. The socket answers all three: binding the address is the
election, a closed connection is a death whatever the process was doing, and
what crosses is offsets and descriptors, which mean the same thing everywhere.

So the segment goes, and with it the pointer surgery, the process-shared mutex,
the magic and version guarding a layout nobody reads any more, and the names of
the five filesystem objects two processes used to find each other by. Reporting
moves onto the same socket: connecting is most of the answer, since a process
that answers is a process holding the controllers.

This is a break. A build with this change cannot share a controller with a build
without it, and the shm_id that used to name a segment now names a socket.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
A caller that wants to use a shared controller has to know whether anybody is
holding one, and a process that starts a primary has to know when it is ready
to be attached to. Both were answerable only by opening a device, which is the
thing a would-be consumer cannot do while somebody else owns it.

So homi asks the runtime instead, over the socket it already serves, and prints
what comes back. It exits zero only when a primary is running and every
controller it holds has finished coming up, which makes it usable as a
readiness check without parsing its output. Asking is not attaching, so whoever
asks does not count itself among the consumers.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
An shm_id of 1 sits where a boolean would, so a value that got coerced
somewhere along the way would still select the same segment and the mistake
would go unnoticed. 42 costs nothing and is unambiguous in a log.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Simon A. F. Lund <os@safl.dk>
@safl

safl commented Aug 25, 2026

Copy link
Copy Markdown
Owner Author

Reopening against xnvme/xnvme instead; this fork-local base was only needed for GitHub's stacking.

@safl safl closed this Aug 25, 2026
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