Skip to content

Reach an offboard server over a Unix socket with a unix:// URL - #735

Open
v-positronic wants to merge 15 commits into
mainfrom
offboard-unix-socket
Open

Reach an offboard server over a Unix socket with a unix:// URL#735
v-positronic wants to merge 15 commits into
mainfrom
offboard-unix-socket

Conversation

@v-positronic

@v-positronic v-positronic commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

A policy process on the same machine now serves the offboard protocol on a Unix domain socket, and the rig reaches it as an ordinary remote endpoint. The handshake, the declared rig-side stack, the session and infer are unchanged. Only the carrier is new.

This is the carrier for a policy that runs beside the harness in a container with no network interface of its own. The container binds a socket path; the host dials it. The endpoint stays kind: remote, and it needs no headers file.

The URL grammar

unix://<absolute socket path>[/api/v1/session[/<model_id>]][?query]

The socket path runs to the first /api/v1 segment, matched as a whole path segment.

URL socket session
unix:///run/policy.sock /run/policy.sock the default
unix:///run/policy.sock?codec.fps=10 /run/policy.sock the default, with a param
unix:///run/policy.sock/api/v1/session/10000 /run/policy.sock model 10000

A relative path is refused. TLS does not apply. headers ride as they do over TCP. Only the socket path is URL-decoded, because it names a file; the URL path reaches the server as written, so a model id keeps its own escapes.

Client

InferenceClient dials the session with unix_connect(path, uri=...), keeping its timeouts and headers, and reads /api/v1/models through httpx.HTTPTransport(uds=path). The handshake asks for the URL path under a stand-in host, because a socket path is not a host; session_url still names the socket, so a failed connect says which one.

A co-located server binds its socket only once the model has loaded, so the client waits for it as it waits for a cold backend over TCP. Two dials can mean the server is still coming: the path is absent, and the path refuses. Every other OSError — a permission the path denies, a limit this process has hit — is settled, and surfaces at once rather than spending the deadline on an answer that will not change. A refusal then reads the path, which tells a restarting server from a path naming something that is not a socket.

Server

PolicyServer(uds=...) and serve --uds bind that socket path in place of host:port. Every vendor server CLI takes the flag, because each one is a config of serve.

The bind is the claim, so two servers starting together cannot both take one path: the loser's bind fails. A socket file an earlier run left is replaced, which lets a restart bind again; a live socket, and anything at the path that is not a socket, are refused and left as they are. The server hands uvicorn the descriptor rather than the path, so uvicorn never unlinks it, and shutdown leaves the file for a successor to read as stale.

The handshake reports the socket path under its own uds key, and carries no host and no port: a filesystem path is not a network address, and a Unix socket has no port. Every document that lists the server's flags names --uds.

Tested

  • A stub pipeline on a socket: list_models, the handshake, its metadata, one infer, and a model id past the socket path.
  • The URL grammar: the whole-segment marker, an escaped separator, a decoded socket path beside an undecoded model id, a refused relative path.
  • A client waiting for a server that has not bound yet, and for one restarting over the socket it left.
  • A dial this process broke — a descriptor limit, over a live socket — surfacing at once instead of waiting out the deadline.
  • A path holding something that is not a socket: the dial fails at once, and the server refuses to bind and leaves the file.
  • A server binding over the socket an earlier run left; one refused a live server's socket; a second claim refused while the first serves on; a claimed socket keeping the umask's mode.

The test socket lives under a short /tmp directory. A macOS tmp_path can pass the 104-byte limit on a socket path, and CI runs the core tests on macOS.

Positronic-Robotics/internal#1242

A policy process on the same machine can now serve the offboard protocol on a
Unix domain socket, and the rig reaches it as an ordinary remote endpoint. The
carrier changes; the protocol does not.

`InferenceClient` accepts `unix://<absolute socket path>[/api/v1/session[/<model_id>]][?query]`.
The socket path runs to the first `/api/v1` segment, so a bare socket path is
the default session, and a model id and session params follow the path as they
follow a host. The client dials the session with `unix_connect` and reads
`/api/v1/models` through an `httpx` transport bound to the same socket. TLS
does not apply.

`PolicyServer(uds=...)` and `serve --uds` bind that socket path in place of
`host:port`, and every vendor server CLI takes the flag. The server removes the
socket file an earlier run left, so a restart binds again; anything else at the
path stays and the bind fails.

Positronic-Robotics/internal#1242

Ticket: Positronic-Robotics/internal#1242 #refs
… tests

The two keys the constants already name read through them, beside the `HOST` and
`PORT` assertions in the same test, so a rename reaches the test.

Ticket: Positronic-Robotics/internal#1242 #refs
The `unix://` grammar has one home per reader: the client's class docstring for a
caller, the offboard README for the protocol. The `serve` docstring, the private
helper and the inference guide each said it again.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c57f4a5eb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/server.py Outdated
Comment thread positronic/offboard/client.py
Comment thread positronic/offboard/server.py
Comment thread positronic/offboard/client.py
Comment thread positronic/offboard/client.py
`clear_stale_socket` judged a socket stale by its file type alone, so a second
server on one path unlinked the first server's address and took its future
connections. It now connects to the socket first: a refused connection means
nobody listens and the file is stale; an accepted one means the socket has an
owner, and the bind fails instead.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 30f6c4bb0d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/server.py Outdated
`clear_stale_socket` left a live socket in place and relied on the bind to fail.
It does not: `asyncio.create_unix_server`, which uvicorn calls, unlinks an
existing socket file before it binds, so a second server on one path took the
first server's future connections and neither side said anything.

The check now raises `EADDRINUSE` naming the path, before uvicorn is built. It is
`claim_socket_path` for that reason: it takes the path or refuses it, and a name
that says only "clear" no longer covers what it does. A refused connection still
means nobody listens, so the file is stale and goes.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2aedb6a2b6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/server.py Outdated
Comment thread positronic/offboard/server.py
Two defects in the same guard, both from defending a path instead of taking it.

The probe told a live server from a stale file but claimed nothing, so two
servers starting against an absent or stale path both passed it and the second
bind took the first. The bind is the claim now: it happens first, and a path
already held fails it. A probe follows only to read what holds the path, and it
answers one question -- is this a socket nobody serves, so replacing it costs
nobody anything.

That probe also blocked with no bound, so a listener with a full backlog held
startup open. It waits `LIVE_SOCKET_PROBE_SEC` and reads a wait that runs out as
a live server.

`serve` hands uvicorn the bound descriptor through `uvicorn.Config(fd=...)`
rather than the path. Uvicorn then calls `create_server(sock=...)`, which binds
nothing, where `uds=` reaches `create_unix_server(path=...)` and unlinks what it
finds. The socket keeps the 0o666 uvicorn gives one it binds itself, and the file
stays behind at shutdown: a successor reads it as stale, and an unlink here could
take a path that successor has already claimed.

Ticket: Positronic-Robotics/internal#1242 #refs
Both described the probe by leading with the reason for it.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 58a3ebdb4b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/client.py Outdated
`urlsplit` leaves a path percent-encoded, and the socket path was dialled as it
came, so a directory holding a space or a percent named a file that is not there.

The socket path is decoded once, after the split. The split still runs over the
encoded path, so an escaped separator stays inside a directory name rather than
becoming one. The URL path keeps its escapes: the server decodes those, which is
what lets a model id carry its own.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ec31e88b1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/server.py
Comment thread positronic/offboard/server.py Outdated
Comment thread positronic/offboard/README.md
Comment thread positronic/offboard/client.py Outdated
The claim chmod'd every socket to 0o666, which reads as the mode uvicorn gives
one and is not: uvicorn's own bind takes the umask, and it copies an existing
file's mode where there is one. So a deployment with a restrictive umask had its
socket widened, and an open server behind it became reachable by any local
account that can walk the directory. The bind's own mode stands.

Two documents said something that is not so. The socket-path docstring claimed an
escaped separator stays inside a directory name; the decode resolves every escape,
so `%2F` becomes a separator and a socket path cannot hold a name carrying a
slash. It says that now, and the test that asserted it is named for it. The
README's serve inventory listed four flags and now lists `--uds` as well.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f09910be49

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/client.py
`serve` binds only once the model has loaded, so the path is absent for that whole
interval. The dial raised `FileNotFoundError` on it at once, which is the state a
co-located server is in for the seconds after its unit starts, so the 900-second
connect budget bought that deployment nothing.

An absent path now rides the deadline, and so does a socket that refuses, which
is a server restarting. A refusal from anything else at the path is a wrong path
and still surfaces at once, as a permission error always did: the two share
`ECONNREFUSED`, so the socket itself is what tells them apart.

`_ConnectRetries` holds the whole retry policy now — the 403 budget it already
had, plus the deadline and the backoff `new_session` was carrying in locals — so
both handlers spend an attempt through one call.

Ticket: Positronic-Robotics/internal#1242 #refs
…ng path

macOS answers a dial at a path holding a regular file with ENOTSOCK, where Linux
answers ECONNREFUSED. The check keyed on the exception, so the two platforms
classified one state differently, and the test that pinned the Linux errno went
red on macOS.

The path answers it on both: absent or a socket means a server may still bind
there, anything else at the path does not. A permission error stays settled
wherever it comes from, since this client cannot read past it to find out.

The test asserts what the code promises — a wrong path does not spend the connect
deadline — rather than the errno that carries it.

Ticket: Positronic-Robotics/internal#1242 #refs
Two were clefts and one told the reader how to take the fact before it.

Ticket: Positronic-Robotics/internal#1242 #refs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5312288a4c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread positronic/offboard/server.py Outdated
Comment thread positronic/offboard/server.py
Comment thread positronic/offboard/client.py
dial = (
partial(connect, self._ws_uri)
if self.uds is None
else partial(unix_connect, self.uds, uri=self._ws_uri)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound the Unix-domain connect operation

When the listener's accept backlog is full, websockets.sync.client.unix_connect performs a blocking Unix sock.connect(path) before the WebSocket connect code applies open_timeout; this attempt can therefore hang indefinitely without reaching the retry deadline. Create and connect the AF_UNIX socket with a timeout or nonblocking deadline before passing it to the WebSocket client.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Declining this one — the connect is already bounded, at the line the finding cites.

websockets.sync.client.connect builds the AF_UNIX socket and sets the deadline on it before connecting:

deadline = Deadline(open_timeout)
...
if unix:
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(deadline.timeout())   # <- before the connect
    assert path is not None
    sock.connect(path)

Checked in both versions this repository can resolve: 16.0, which the lockfile pins (.venv/.../websockets/sync/client.py:291-294), and 15.0.1, the floor pyproject.toml declares (src/websockets/sync/client.py:288-291). Identical in both. A full accept backlog therefore raises TimeoutError after open_timeout, which new_session already catches and spends against connect_deadline, rather than hanging.

Creating and connecting the socket here would duplicate that, and would take on unix_connect's remaining setup to hand it a sock=. Leaving the thread open for the driver's word rather than resolving it myself.

…ls that clear

The handshake metadata put a socket path under `host`, which labels a filesystem path
as a network address wherever an episode's metadata is read. It gets `uds`, and a
server reports one pair or the other.

The client waited out the whole connect deadline on any `OSError` the path answered
as a socket, including one this process raised itself — a descriptor limit, a memory
limit. Only an absent path and a refusal can mean a co-located server that has not
bound yet, so every other dial surfaces at once.

Every document that lists the server's flags now names `--uds`: the offboard README,
the five vendor READMEs, and the training workflow. The enumeration is complete.

Ticket: Positronic-Robotics/internal#1242 #refs
`LIVE_SOCKET_PROBE_SEC` sat at the top of the file with its one user 400 lines
below. It is `PolicyServer`'s now, above the method that spends it.

`claim_socket_path`'s docstring described what its caller hands to uvicorn, which
nothing keeps true. It states the constraint on the returned socket instead.

Ticket: Positronic-Robotics/internal#1242 #refs
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