feat(bindings): the Python reference server for the local API - #491
Merged
Merged
Conversation
…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
force-pushed
the
feat/headless-local-api-server
branch
from
September 30, 2026 19:36
eeb48d9 to
11a94d1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_apiand as theoffline-protocol-servicecommand.Stacked on #486 and gets CI only once it retargets to
main; every gate below was run locally.ProtocolManager's one event handler, starts the manager and its peer-stream transport when configured, and constructs theMeshServicesandDataStorehandles once. No client can callprocess(),receive_message()or any other platform operation: a request naming one gets-32601, the same answer an unknown name gets.bindings/python/scripts/generate_local_api_table.pyparses the UDL intolocal_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.pyclassifies all 218 wire names asEXPOSED(126) orPLATFORM(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).mux.py): stamped events reach the sessions of theirapp_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 thehelloresult and before anything newer, because every frame a connection receives goes through one queue and one writer task.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, ahellounder an id no entry names isPermissionDenied. Service ownership never turns an open server to default-deny, and a test pins that.code = -32000 - positionin the definition's error enum, derived from the enum order and not from the binding's one-based discriminant,data.variantthe variant name,messagethe engine's text. Session refusals reuseInvalidState,InvalidArgumentandPermissionDenied.local_api_tables_partition_the_definition, reads the chapter, the UDL,dispatch.pyandevents.rsand asserts the two chapter tables partition the definition, thatdispatch.py's two sets equal the chapter's, and that the catalogue's tags are exactly theEventvariants. Skip-if-tree-absent, like the other document readers.What changed from the plan, and why
POSTfor one-shot calls through the request hookGETand drops aPOSTwith no response before the hook runs. A one-shot call is a connection with one request; the hook servesGET /healthonly, and a test pins that aPOSTreceives zero bytes (verified on websockets 16.1, the lock)message_receivedfromreceive_message()'s JSON and hands it to the same handler as the engine's event. The server drops that copy by its shape (nomessage_id, because the drain's JSON keys the idid) 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 seesNoneand no copy is ever made there; Nostr, and a message the engine releases on a laterprocess()tick, do reach the drain. The two-server test over the peer stream therefore cannot exercise the filter, andtest_local_api_drain.pyfeeds the seam directly: the engine's event, then the drain's copy of the same message, and exactly onemessage_receivedreaches the clientapplicationslist for ids with no rule of their own; on its own it configures nothingWhat the dispatcher stamps
send_messageandsend_mediaexecute throughsend_message_richandsend_media_richwith onlyapp_id(and the caller's priority, reply and metadata) set; the rich twins refuse anoptions.app_idthe client sent withInvalidArgument. 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.
hello.app_id, a 400-digitamountfor adouble, a non-ASCII TCP token)-32603 "<method>: internal error"on an open connection and logs the traceback; the three shapes are refusals in the taxonomy (InvalidArgument,-32602,PermissionDeniedwith close 1008). Tested, including that the connection still answers afterwards0700whatever the operator named (fails on/tmp, silently narrows a0755directory), and a regular file at the socket path was unlinked0700; 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 issequence<u8>per element in pure Python (1 MiB 1.45 s, 4 MiB 5.9 s with a 5.77 sprocess()gap, 24 MiB 36 s; the frame limit admits 134 MiB), and during it nothing tickedasyncio.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 throughcall_soon_threadsafeahead 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 newin_callflag on the router, with a test for each side)._handle_frameis async; per-connection order is preserved by theasync for. The test monkeypatches one exposed method to sleep 1 s and asserts that during itprocess()ticks,GET /healthanswers, a late client is greeted and receives its held event, and that a second client's call waits for the locksign_dat,tunning) was accepted and denied nothingOrderedDictcapped 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 dropfloatis a number, as JSON-RPC 2.0 says; the chapter's wording standsNo 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.
process()tick or a peer-stream inbound landing there emits within_callfalse and an id not yet in the issued map, andmessage_sentcarriescontent, 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 afternote_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-threadmessage_sentfor 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 largedata.export_raw, an MLS operation) holds the engine's own lock, andprocess(), 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 40on each interpreter (1920 green per interpreter) under the pyc-safe discipline, the whole Python suite on 3.14 (521), the guard andcargo fmt --checkgreen; 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
tests/local_apipass on 3.12, 3.13 and 3.14; the two test files the fixes touched ran underpytest-repeat --count 40on each interpreter (1760 green per interpreter) withPYTHONDONTWRITEBYTECODE=1and 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.notesclient of one server reaches only thenotesclient of the other, stampednotes;message_sentandmessage_deliveredreach only the sending client; a message for an application with no client is held and replayed in order after the late client'shello.app_idaccepted, a claim kept after unregister or after a refused registration, inside-call attribution off, a stamped event withoutapp_idheld, the error code sign, the space scope open, the method deny off, methods beforehelloallowed, a platform operation reachable, the token unchecked, held events ahead of thehelloresult, the subscription filter ignored) each fail a test.cargo fmt --all -- --check,cargo clippy -p offline-protocol-uniffi -- -D warnings, the new guard green, and negative-controlled: removing one row fromdispatch.pyfails it with the set difference in the message.generate_local_api_table.py --checkreports the checked-in table fresh;test_table_is_fresh_against_the_definitiondoes the same in the suite.Not in this PR
run_mls_storage_conformanceor the peer-stream transport's own configuration over the wire; the peer stream is configured on the command line.server.pysays so.Notes for reviewers
--helpneeds the native library: the package's__init__loads it, and the CLI module lives inside the package.open(O_CREAT | O_EXCL,0600); a stale file from an earlier launch is removed first.transport_switchedcarriesto: "None"as a string on a carrier disconnect whilefromis a JSONnull, and the transport labels differ between events; nothing is coerced.