Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/inference.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Accepted forms: `host`, `host:port`, and `https://host[:port][/api/v1/session[/<

**A Unix socket reaches a server on the same machine.** `--uds /run/policy.sock` binds that socket path in place of a host and a port. `--policy.url=unix:///run/policy.sock` dials it, over no network. A model id and session params follow the socket path as they follow a host: `unix:///run/policy.sock/api/v1/session/10000?codec.fps=10`. Use this carrier for a policy process that runs beside the harness and has no network interface of its own.

Such a server also takes each observation's images through shared memory, which keeps 8 MB of frames out of the message; `--frame_ring=false` turns that off. [`positronic/offboard/README.md`](../positronic/offboard/README.md) states the contract.

**Credentials stay out of the URL, and out of the command line.** The URL is meant to be safe to paste around, so a token rides a header instead. It stays off the command line too: `save_run_metadata()` writes `sys.argv` beside the run's episodes. Three policy configs build the header:

- `.authed_remote` — a bearer token read from `AUTH_TOKEN`, which it raises about when that is unset. Every endpoint [`workflows/nebius/serve.sh`](../workflows/nebius/README.md) creates is gated this way, whether the server checks the token itself or a proxy in front of it does.
Expand Down
34 changes: 33 additions & 1 deletion positronic/offboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ A `unix://` URL reaches a server on the same machine over a Unix socket, which n
binds the path with `--uds`, and `unix:///run/policy.sock[/api/v1/session[/<model_id>]][?query]` dials it. The
socket path runs to the first `/api/v1` segment; everything after it is the URL path the server reads.

#### The frame ring

A server on a Unix socket carries each observation's images through shared memory instead of the message.
It declares `frame_ring` in the ready handshake, with this session's id as the value. A client that dialled a
`unix://` URL then creates a ring, hands the descriptor over, and sends a reference in place of every image.
A client that ignores the declaration keeps sending whole images, and so does every client over TCP.

- **The ring is a sealed `memfd`.** The client maps it writable, seals it with
`F_SEAL_SHRINK | F_SEAL_GROW | F_SEAL_FUTURE_WRITE`, and only then hands the descriptor over. The server maps
it read-only and cannot write it, resize it, or punch a hole in it. That holds whatever the server's code
does, so a server that runs untrusted code gets the frames and no way to change them.
- **The descriptor travels beside the session socket.** The server binds a second `AF_UNIX` socket at the
session socket's path plus `.frames`, of type `SOCK_SEQPACKET`, and the client dials the same suffix on the
path it dialled. Each side builds that path from the socket path it already holds, so a bind mount that gives
the two processes different names for one directory still lands them on the same socket. The client sends the
descriptor with `SCM_RIGHTS`, names the session id from the handshake, and waits for the server to map it.
- **A ring holds four slots.** One round trip is in flight at a time, so the writer returns to a slot four
inferences later, and a server that still reads an earlier observation reads the bytes written for it. Each
slot carries a sequence number before its payload and one after it; a reader that finds either one different
from the reference refuses that observation rather than serving other pixels under it.
- **A larger frame grows the ring.** The client creates a bigger one and hands it over before it sends any
reference to it. The server keeps every mapping it was handed, so a view it built earlier stays readable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Describe mapping retention as implemented

Rule stale-doc violated:
The frame-ring contract says the server keeps every mapping, but FrameChannel._take_ring now retains only the newest MappedRing; an older mapping survives only when a NumPy view still references its mmap. Update this bullet to describe that ownership model so readers do not expect the channel itself to retain every historical mapping.

AGENTS.md reference: AGENTS.md:L14-L22

Useful? React with 👍 / 👎.

- **The views are read-only.** Code that writes an observation's image in place raises; a codec that resizes or
copies is unaffected.

`--frame_ring=false` on the server keeps every image in the message. A server declares no ring where the
kernel seals no `memfd` — a macOS server, or Linux before 5.1 — or where the session socket's path plus
`.frames` is longer than a Unix socket address may be.

### WebSocket Flow

#### 1. Handshake
Expand All @@ -100,7 +129,8 @@ Upon connection, the server sends a ready packet with metadata:
{"name": "restrict_image_size", "args": {"width": 224, "height": 224}}
]},
"compress_images": false,
"positronic_version": "0.2.1"
"positronic_version": "0.2.1",
"frame_ring": "9f2c1ab4e7d05613"
}
}
```
Expand All @@ -122,6 +152,8 @@ This metadata tells the client:
- `compress_images` — the `remote` marker's own wire setting: whether the rig JPEG-encodes frames before
sending, for an endpoint behind a proxy with a message-size cap
- `positronic_version` — the server's positronic version, for diagnosing declaration mismatches
- `frame_ring` — this session's id, present when the server takes images through shared memory (see above);
absent when it does not

#### 2. Status Updates (Long Model Loading)

Expand Down
24 changes: 21 additions & 3 deletions positronic/offboard/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from websockets.sync.client import connect, unix_connect
from websockets.sync.connection import Connection

from . import frame_ring as frames
from . import keys as offboard_keys
from . import protocol
from .protocol import deserialise, serialise, typed_commands

Expand All @@ -27,10 +29,24 @@


class InferenceSession:
def __init__(self, websocket: Connection, infer_timeout: float = DEFAULT_INFER_TIMEOUT):
"""One session on one server, and the observations it sends.

``uds`` is the Unix socket the session was dialled over, which says the server runs on this host.
A server that also declares a frame ring then gets every image through shared memory, and the
message carries a reference in place of each one.
"""

def __init__(self, websocket: Connection, infer_timeout: float = DEFAULT_INFER_TIMEOUT, uds: str | None = None):
self._websocket = websocket
self._infer_timeout = infer_timeout
self._metadata = self._handshake()
self._frames = self._frame_writer(uds)

def _frame_writer(self, uds: str | None) -> frames.FrameWriter | None:
session_id = self._metadata.get(offboard_keys.FRAME_RING)
if uds is None or session_id is None or not frames.SUPPORTED:
return None
return frames.FrameWriter(frames.channel_path(uds), session_id)

def _handshake(self, timeout_per_message: float = 30.0) -> dict[str, Any]:
"""Receive status updates until server is ready.
Expand Down Expand Up @@ -72,7 +88,7 @@ def infer(self, obs: dict[str, Any]) -> Any:
arrays/scalars, and no arbitrary Python objects. The result is whatever the server's session
returned — canonically a list of action dicts, but a bare dict or ``None`` too.
"""
serialised = serialise(obs)
serialised = serialise(obs if self._frames is None else self._frames.pack(obs))
logger.debug('Size of serialised obs: %1.f KiB', len(serialised) / 1024)

self._websocket.send(serialised)
Expand All @@ -94,6 +110,8 @@ def infer(self, obs: dict[str, Any]) -> Any:
return typed_commands(response[protocol.RESULT])

def close(self):
if self._frames is not None:
self._frames.close()
state_before_close = self._websocket.state.name
self._websocket.close()
# A close that times out still reaches CLOSED locally; only the close code says the server answered.
Expand Down Expand Up @@ -281,7 +299,7 @@ def new_session(self) -> InferenceSession:
else partial(unix_connect, self.uds, uri=self._ws_uri)
)
ws = dial(open_timeout=self.open_timeout, additional_headers=self.headers, ping_interval=20.0)
return InferenceSession(ws, infer_timeout=self.infer_timeout)
return InferenceSession(ws, infer_timeout=self.infer_timeout, uds=self.uds)
# ``SSLCertVerificationError`` is an ``ssl.SSLError``, but a bad certificate is permanent
# misconfiguration, not a cold start — surface it immediately instead of retrying to the deadline.
except ssl.SSLCertVerificationError as e:
Expand Down
Loading