Repository navigation
feat: poll-driven Q subscriptions and serving from an embedded runtime - #20
Merged
protocolstardust merged 12 commits intoOct 12, 2026
Merged
Conversation
- compile rayforce-q's q_server.c beside q.c, and bind ray_poll_get, ray_fn_unary, ray_fn_vary, ray_env_bind and ray_env_bind_flat from the core's own poll.h and env.h (CORE_PRIVATE_HEADERS / INTERNAL_FNS) - q.c's client is blocking request/response, so an unsolicited frame is read as the next reply; q_server.c routes frames by type off a poll - headers rather than a hand-copied block: C linkage matches on name alone, so a drifted copy is UB with no diagnostic, and ray_selector is a struct this crate dereferences - rayforce-q 2.1.0 is a hard floor (q_conn_* is absent in 2.0.0); the vendored checkout is already past it - q_server.c/h join the crate's include list and CI's packaged-crate check, so a published build.rs can find them
- a handler is a type implementing env::VaryFn / env::UnaryFn, bound under a name with env::bind_vary / env::bind_unary - the core takes a bare extern "C" fn with no user-data slot, so a closure cannot be bound; a monomorphized trampoline per type makes the function address carry the identity - the trampoline borrows the argument array and catches panics, so the FFI contract is not on every implementor - both binders can refuse (capped table, reserved names), so both return codes are checked
- QConnection::attach hands the socket to the runtime's poll and returns a Subscription; Poll is a borrow of the loop the runtime owns - the blocking client reads a pushed frame as the next reply, so a subscription is impossible unless the poll owns the reads - Poll pins no heap handle and checks the runtime is live before every dereference; a pinned handle would keep a selector alive that nothing is left to close - attach forgets the QConnection to suppress q_close and releases the heap handle by hand; missing it strands the heap until an unrelated Runtime::new is refused - a Subscription is identified by selector id, rx data pointer and a claim token: ids are slot indices reused on disconnect, so an id compare alone closes the live peer that inherited the slot
- ipc.md gains a subscription section: why the blocking client cannot express one, and the Poll / attach / bind shape that can - changelog entries sit under a fresh Unreleased heading above the shipped 1.1.0; Poll and Subscription being !Send is noted there rather than by editing the released list
- q::encode turns a Value into a complete wire message, the mirror of q::decode_response, so a process can publish over a socket it owns - the header is always stamped SYNC; a publisher retypes byte 1 in place - there is no native dict branch, so a true q dict (type 99) means splicing the marker between separately encoded keys and values
- Poll and Subscription get the same compile_fail pair plus control as Value; both carry a runtime generation checked with the same load - Subscription<'static> is named on purpose: written bare the snippet fails for a missing lifetime, a pass for the wrong reason - one marker per block: a combined block keeps passing when only one regresses (verified with an unsafe impl on each alone)
- the Q wire builders move to tests/support/wire.rs; each tests/ file is its own crate, so they lived twice and a third copy was due for the listener tests - the server-side pieces come with them: a char vector, a long atom, and a frame reader that returns the message type with the body - q.rs keeps its own copy until next touched, so this moves code rather than rewriting it
- Poll::serve_q(port) registers rayforce-q's listener on the runtime's poll and returns a QListener: sync strings are evaluated and answered, async (upd; payload) dispatches to whatever upd names - a runtime defining upd in Rayfall and serving a port is rayforce -q embedded, which an RDB written from Rust could not get before - QListener is a handle, not an owner: the public header has no ray_poll_deregister, so the socket closes with the runtime - !Send/!Sync like every other handle, with the same compile_fail pair - tests drive the listener from raw TCP peers on helper threads; a port-in-use test was dropped because the core binds with address reuse
- reverts 61a9553: with `git@github.com:` in .gitmodules, every `git = "https://..."` consumer of this crate fails to fetch, because Cargo checks a dependency's submodules out through libgit2 and offers it no SSH key - https reads a public submodule from any clone, SSH origin or not, and a container or a CI runner without a key needs no url rewrite and no `net.git-fetch-with-cli` - the README, the installation page, the changelog and the workflow comments drop the SSH note they carried
- tests/support/ipc.rs speaks the wire `rayforce -p` serves: the 2-byte handshake, a SYNC frame holding one string, and an answer decoded as far as a test reads it, a long or an error code - on raw sockets, because a TcpClient needs a runtime on its thread and the one a process may hold is the server's under test - libc as a dev-dependency, for the one thing std cannot send: the TCP urgent byte that is the core's cancel
- Poll::serve_ipc(port) is a builder, with budget and clock, whose start listens through `.sys.listen` and installs a guard; IpcServer::drain hands back each request's end: the whole text, how long it was evaluated, and Answered, Failed(code) or Cancelled(by whom) - the server evaluates one request at a time and the core stops one only when its own client cancels, so a request that ran for minutes held every other client for minutes, a writer on the Q wire included - the guard is the core's .ipc.on.sync/.async hooks, a one-line lambda over a native function, since the core calls a hook only when it is a lambda; a watcher thread interrupts past the budget or when a peek reads the client gone, under a lock that keeps an interrupt from landing after the request ends - the Q wire never reads the hooks, so its writer is never cancelled; it can still wait for every request queued ahead of it, because the core orders the two wires itself, and the writer test pins that bound - one per process, the hooks being globals; dropping the handle stops the guard, not the serving, as with QListener
feat(serve): serve the native IPC with a budget on every request
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Runs on the core and rayforce-q already pinned on
master(core v2.11.0dc9b12a, rayforce-q 2.2.0139e4fd); no submodule moves. The branch is three commits behindmaster(the table column validation fix) and merges cleanly.What
Subscriptions and the Q wire
QConnection::attachhands a connection to the runtime's event loop and returns aSubscriptionthat receives frames the peer pushes unsolicitedPollis a borrow of the loop the runtime owns;Poll::serve_q(port)registers rayforce-q's listener on it, so a runtime that definesupdisrayforce -qembeddedenv::bind_vary/env::bind_unarybind a Rust handler type under the name a publisher calls; the generated trampoline borrows the arguments and catches panicsq::encodeis the mirror ofq::decode_response, so a process can publish over a transport it ownsrayforce-syscompilesq_server.cbesideq.cand binds the poll and env symbols from the core's own headersServing the native IPC
Poll::serve_ipc(port)returns aServeIpcbuilder withbudgetandclock. Itsstartlistens through.sys.listen, installs a guard over every request, and returns anIpcServerIpcServer::drainhands back each request's end: the whole text, how long it was evaluated, andAnswered,Failed(ErrorCode)orCancelled(Budget | Hangup | Client)Build
git@github.com:in.gitmodules, everygit = "https://..."consumer of this crate fails to fetch: Cargo checks submodules out through libgit2 and offers it no SSH keyWhy
q.c's client is blocking request/response: a frame a publisher pushes between two requests is consumed by the nextq_sendas its own reply, and every response after it is one frame out of step. The only fix is for the poll to own the reads and route frames by message type, which is what rayforce-q 2.1.0'sq_server.cdoes — hence the floor on that version.The native server evaluates one request at a time, and the core stops a request only when its own client cancels it. In an RDB built on this crate, queries that ran 120 to 214 s held every other client, and the writer on the Q wire, for that long. A client-side timeout does not help: the client drops the connection and the query runs on.
How the IPC guard works
.ipc.on.syncor.ipc.on.asyncis set, the core passes it each request's payload in place of evaluating it, but only if the hook is a Rayfall lambda (hook_lookup,src/core/ipc.c). So the hook is a one-line lambda over a native function bound withenv::bind_unaryray_request_interruptwhen a request passes the budget, or when a non-blocking peek on the request's socket reads zero bytes. The socket comes from.ipc.handleandray_poll_getLimits
IpcServerorQListenerstops the guard, not the serving: the public header has noray_poll_deregister, so the socket closes with the runtimeVerification
At this branch's HEAD, on macOS:
cargo fmt --all -- --checkandcargo clippy --workspace --all-targets -- -D warnings: cleancargo test --workspace: 168 passed, 0 failed, including 12 inq_subscribe.rs, 3 inq_serve.rsand 8 inipc_serve.rscargo package -p rayforce-sys --listcarriesq_server.c/q_server.hPoll,SubscriptionandQListenerare!Send/!Sync, each pinned by acompile_faildoctest paired with a control.IpcServercarries the same marker but no doctest yet🤖 Generated with Claude Code