Skip to content

feat(bindings): the Python reference server for the local API - #491

Merged
bahdotsh merged 3 commits into
mainfrom
feat/headless-local-api-server
Sep 30, 2026
Merged

bahdotsh merged 3 commits into
mainfrom
feat/headless-local-api-server

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

The reference server for the local API chapter (#486): one process owns one engine and serves any number of local applications over JSON-RPC 2.0 on a WebSocket, on an owner-only Unix domain socket by default or on loopback TCP with a per-launch token. Ships in the Python package as offline_protocol_sdk.local_api and as the offline-protocol-service command.

Stacked on #486 and gets CI only once it retargets to main; every gate below was run locally.

  • The server owns the run loop and the drain. It registers itself as the ProtocolManager's one event handler, starts the manager and its peer-stream transport when configured, and constructs the MeshServices and DataStore handles once. No client can call process(), receive_message() or any other platform operation: a request naming one gets -32601, the same answer an unknown name gets.
  • The method table is data, generated from the interface definition. bindings/python/scripts/generate_local_api_table.py parses the UDL into local_api/table.py (every declaration with its parameters and result type, every enum in order, every dictionary with which fields have defaults, the error enum). dispatch.py classifies all 218 wire names as EXPOSED (126) or PLATFORM (92). A parameter is decoded by its declared type and a result encoded by it (codec.py): enums by the definition's spelling, bytes as base64, dictionaries field by field with an omitted field left to the definition's default (C6).
  • Routing (mux.py): stamped events reach the sessions of their app_id, or are held for that id when none is connected; an event emitted while the server is inside one client's call is that client's, and the identifiers it names become that client's; an event naming an identifier the server handed out, or a service its application owns, reaches that application; everything else is broadcast. Held events are delivered after the hello result and before anything newer, because every frame a connection receives goes through one queue and one writer task.
  • The four server-side rules (authz.py): service ownership as a runtime shadow, a space allow-list of glob patterns, method-group denials (sign_data, manual_mls, tuning, or any single wire name), and the unlisted-id rule: once a space allow-list or a method deny is configured, a hello under an id no entry names is PermissionDenied. Service ownership never turns an open server to default-deny, and a test pins that.
  • Errors are the engine's taxonomy: code = -32000 - position in the definition's error enum, derived from the enum order and not from the binding's one-based discriminant, data.variant the variant name, message the engine's text. Session refusals reuse InvalidState, InvalidArgument and PermissionDenied.
  • A Rust guard in the FFI crate, local_api_tables_partition_the_definition, reads the chapter, the UDL, dispatch.py and events.rs and asserts the two chapter tables partition the definition, that dispatch.py's two sets equal the chapter's, and that the catalogue's tags are exactly the Event variants. Skip-if-tree-absent, like the other document readers.

What changed from the plan, and why

Point The plan What is built
HTTP POST for one-shot calls through the request hook None. The pinned library's handshake parser accepts only GET and drops a POST with no response before the hook runs. A one-shot call is a connection with one request; the hook serves GET /health only, and a test pins that a POST receives zero bytes (verified on websockets 16.1, the lock)
The drain's return value Not discussed The Python manager synthesises a second message_received from receive_message()'s JSON and hands it to the same handler as the engine's event. The server drops that copy by its shape (no message_id, because the drain's JSON keys the id id) and relays only the engine's event. The seam is the server's handler rather than the manager, so the manager's event shape for embedded applications is unchanged. Verified while testing: on four of the five carriers (BLE, the relay, the peer stream, the gateway) the FFI drains inside its inbound entry point, so the manager's drain sees None and no copy is ever made there; Nostr, and a message the engine releases on a later process() tick, do reach the drain. The two-server test over the peer stream therefore cannot exercise the filter, and test_local_api_drain.py feeds the seam directly: the engine's event, then the drain's copy of the same message, and exactly one message_received reaches the client
Application-id admission Not discussed The chapter's unlisted-id rule. The policy file also takes an applications list for ids with no rule of their own; on its own it configures nothing
Correlated class Two classes Three, per the chapter: an event emitted inside a client's call is the caller's. Calls run one at a time server-wide, on the executor behind one lock (see the review fixes), so the attribution is exact and the loop stays free

What the dispatcher stamps

send_message and send_media execute through send_message_rich and send_media_rich with only app_id (and the caller's priority, reply and metadata) set; the rich twins refuse an options.app_id the client sent with InvalidArgument. Group sends carry the configured id, as the chapter says.

Review fixes (second commit)

Six verified findings from the first review, fixed as an added commit so the PR stacked on this branch keeps its base.

Finding Fix
A value the decoders did not foresee closed the connection with 1011 and a server-side traceback (a lone surrogate in hello.app_id, a 400-digit amount for a double, a non-ASCII TCP token) The frame handler answers every unforeseen failure with -32603 "<method>: internal error" on an open connection and logs the traceback; the three shapes are refusals in the taxonomy (InvalidArgument, -32602, PermissionDenied with close 1008). Tested, including that the connection still answers afterwards
The socket's directory was narrowed to 0700 whatever the operator named (fails on /tmp, silently narrows a 0755 directory), and a regular file at the socket path was unlinked A directory the server creates is made 0700; one that exists must be this user's with no group or other bits and is refused by name otherwise, never narrowed; only a socket is removed from the path. Checked before the engine starts. Three tests: the wide directory is refused and its mode unchanged, the regular file is refused and kept, an owner-only directory is used as is
Every engine call ran on the event loop's thread: a media send marshals sequence<u8> per element in pure Python (1 MiB 1.45 s, 4 MiB 5.9 s with a 5.77 s process() gap, 24 MiB 36 s; the frame limit admits 134 MiB), and during it nothing ticked Every engine call runs on the default executor behind one server-wide asyncio.Lock, with the caller set while the lock is held. Calls stay serialised, so correlation attribution stays exact: an event the engine emits on the executor thread reaches the loop through call_soon_threadsafe ahead of the call's own completion and is routed as the caller's; an event the run loop emits on the loop thread is never the caller's (a new in_call flag on the router, with a test for each side). _handle_frame is async; per-connection order is preserved by the async for. The test monkeypatches one exposed method to sleep 1 s and asserts that during it process() ticks, GET /health answers, a late client is greeted and receives its held event, and that a second client's call waits for the lock
A misspelled deny (sign_dat, tunning) was accepted and denied nothing Every denied name must be a method group or an exposed method, or the policy is refused at load naming the entry (a platform operation is refused too: it is not on the wire)
The issued-identifier map grew for the process's life An OrderedDict capped at 65536, oldest evicted; an identifier is forgotten after its terminal event (message_delivered, message_failed, message_undeliverable, connection_request_undeliverable, media_sent, media_send_failed, service_response_received) is routed. Tests for the cap and the drop
A fractional request id was refused A non-bool float is a number, as JSON-RPC 2.0 says; the chapter's wording stands

No chapter edit is needed: the chapter's "a string or a number", "MAY execute requests from one connection concurrently or in order" and "removes a stale socket file" all remain true of this server.

Second re-check (third commit)

Two residuals, both landed as a further added commit.

  • A loop-thread event for an id the call is about to return. Between the executor's completion and the task wakeup that records the result, a loop iteration or two run; a process() tick or a peer-stream inbound landing there emits with in_call false and an id not yet in the issued map, and message_sent carries content, so the router broadcast it: one application's message in every other application's stream, rare (the window is microseconds against the 100 ms tick) and real. While a call holds the lock, the router now parks any loop-thread event that names an identifier nobody owns; the dispatcher flushes the parked events through the ordinary rules right after the call's identifiers are recorded, or after the call has failed (they then broadcast or drop by the rules, never with the caller's ids unknown), and before any other call can take the lock. Pinned at the router with no timing (park, flush after note_ids, flush after a failure, and the three cases that must not park: no call in flight, a known id, a stamped event), and at the server by a fake engine call that schedules a loop-thread message_sent for the id it is about to return ahead of its own completion, asserting it reaches only the caller.
  • process() under an engine-slow call. The "Calls" row now says the executor frees the loop from the Python marshalling cost only: a call slow inside the engine (a large data.export_raw, an MLS operation) holds the engine's own lock, and process(), a synchronous call on the loop, blocks on it for as long as the call does.

Validation for this commit: the 66 local API tests pass on 3.12, 3.13 and 3.14, with the two touched test files under pytest-repeat --count 40 on each interpreter (1920 green per interpreter) under the pyc-safe discipline, the whole Python suite on 3.14 (521), the guard and cargo fmt --check green; five mutants (the park removed, the park ignoring whether the id is known, a flush that never routes, a dispatcher that never flushes, a flush before the ids are noted) each fail a test.

Validation

  • Python: the 62 tests in tests/local_api pass on 3.12, 3.13 and 3.14; the two test files the fixes touched ran under pytest-repeat --count 40 on each interpreter (1760 green per interpreter) with PYTHONDONTWRITEBYTECODE=1 and every __pycache__ purged first; the drain-seam pair ran forty-fold on each interpreter too; the whole Python suite passes on 3.14 (517). 3.10 is not installed here; CI runs it.
  • Mutation-checked for the fixes under the pyc-safe discipline (caches purged before and after each mutant, no bytecode written, verdict from the subprocess return code): sixteen mutants (the catch-all removed, the surrogate and the overflow uncaught, the token ASCII check removed, the wide directory accepted, a non-socket unlinked, the created directory not narrowed, the call run on the loop, the server lock removed, loop-thread events attributed to the caller, executor events not marked as the caller's, a misspelled deny accepted, the issued map unbounded, a terminal event keeping its id, a fractional id refused, and the first pass's one same-size mutant, the error-code sign, re-run) each fail a test.
  • The end-to-end tests bring up two servers with two real engines over the peer-stream transport on loopback: a message sent by a notes client of one server reaches only the notes client of the other, stamped notes; message_sent and message_delivered reach only the sending client; a message for an application with no client is held and replayed in order after the late client's hello.
  • Mutation-checked: eighteen mutants (the drain copy relayed, the hold cap off by one, admission always open, ownership counting for admission, the stamp dropped, a client-supplied app_id accepted, a claim kept after unregister or after a refused registration, inside-call attribution off, a stamped event without app_id held, the error code sign, the space scope open, the method deny off, methods before hello allowed, a platform operation reachable, the token unchecked, held events ahead of the hello result, the subscription filter ignored) each fail a test.
  • Rust: cargo fmt --all -- --check, cargo clippy -p offline-protocol-uniffi -- -D warnings, the new guard green, and negative-controlled: removing one row from dispatch.py fails it with the set difference in the message.
  • generate_local_api_table.py --check reports the checked-in table fresh; test_table_is_fresh_against_the_definition does the same in the suite.

Not in this PR

  • The user guide and the client examples (PR12).
  • Exposing run_mls_storage_conformance or the peer-stream transport's own configuration over the wire; the peer stream is configured on the command line.
  • Peer credentials on the Unix socket are not read: the file's mode is the boundary, as the chapter says, and a comment in server.py says so.

Notes for reviewers

  • --help needs the native library: the package's __init__ loads it, and the CLI module lives inside the package.
  • The token file is created with its mode in one open (O_CREAT | O_EXCL, 0600); a stale file from an earlier launch is removed first.
  • Events are relayed as the engine serialised them: transport_switched carries to: "None" as a string on a carrier disconnect while from is a JSON null, and the transport labels differ between events; nothing is coerced.
  • Not run against a real deployment; every run is on one host.

…hat it cannot decode

Six review findings on the reference server.

A value the decoders did not foresee (a lone surrogate in hello.app_id, an
integer a double cannot hold, a non-ASCII TCP token) closed the connection
with 1011 and a server-side traceback. The frame handler now answers every
unforeseen failure with an internal-error object on an open connection and
logs the traceback; the three shapes are refusals in the taxonomy (invalid
argument, invalid params, permission denied and close 1008).

The Unix socket's directory was narrowed to 0700 whatever the operator
named, and a regular file at the socket path was unlinked. A directory the
server creates is made 0700; one that exists must be this user's with no
group or other bits and is refused by name otherwise; only a socket is
removed from the path. Both are checked before the engine starts.

Every engine call ran on the event loop's thread, so a media send (about
1.5 s per MiB of pure-Python marshalling, 4 MiB measured at 5.9 s) stopped
process(), the drain, every other connection and GET /health for its
length. Calls now run on the default executor behind one server-wide lock:
serialised, so the caller rule's attribution stays exact, with the loop free.
An event the engine emits on the executor thread reaches the loop through
call_soon_threadsafe ahead of the call's completion and is marked as the
caller's; an event the run loop emits on the loop thread never is.

A misspelled deny was accepted and denied nothing; every denied name must
now be a method group or an exposed method, or the policy is refused at load
naming the entry. The issued-identifier map was unbounded; it is capped at
65536, oldest evicted, and an identifier is forgotten on its terminal event.
A fractional request id was refused; JSON-RPC 2.0 allows it.
…return

Between the executor's completion and the task wakeup that records a
call's result, a loop iteration or two run. A process() tick or a
peer-stream inbound landing there emits on the loop thread, with the
identifier not yet recorded, and a message_sent carries the content: the
router broadcast it, one application's message in every other
application's stream. Rare (the window is microseconds against a 100 ms
tick) and real.

While a call holds the lock, the router now parks any loop-thread event
that names an identifier nobody owns, and the dispatcher flushes the parked
events through the ordinary rules once the call's identifiers are recorded,
or after the call has failed. Pinned at the router with no timing, and at
the server by a fake engine call that schedules the event ahead of its own
completion.

The bridge page's "Calls" row also says what the executor does not free:
process() is a synchronous call on the loop and blocks on the engine's own
lock for as long as a call that is slow inside the engine holds it.
@bahdotsh
bahdotsh force-pushed the feat/headless-local-api-server branch from eeb48d9 to 11a94d1 Compare September 30, 2026 19:36
@bahdotsh
bahdotsh changed the base branch from docs/headless-local-api-spec to main September 30, 2026 19:36
@bahdotsh bahdotsh closed this Sep 30, 2026
@bahdotsh bahdotsh reopened this Sep 30, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 30, 2026
@bahdotsh
bahdotsh merged commit 71024b6 into main Sep 30, 2026
23 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant