feat(rust): give the C ABI a client over the transport's connector - #747
Draft
wkirschenmann wants to merge 9 commits into
Draft
wkirschenmann wants to merge 9 commits into
wkirschenmann wants to merge 9 commits into
Conversation
☂️ Python Coverage
Overall Coverage
New FilesNo new covered files... Modified FilesNo covered modified files...
|
wkirschenmann
force-pushed
the
wk/feat/rust-ffi-client
branch
from
August 9, 2026 08:37
6bec3e9 to
ab40475
Compare
wkirschenmann
force-pushed
the
wk/feat/rust-ffi-client
branch
from
August 9, 2026 10:40
ab40475 to
c1de125
Compare
`ak_client_create` reads a UTF-8 JSON document of the transport's flat option vocabulary, resolves the origin a request is addressed to, assembles the connector `armonik-transport` builds from it, and wraps that in a `hyper_util` pool. `ak_client_release` gives up the caller's reference to it, through the same reference-counted registry every handle of this crate lives in. Nothing is opened here. The options are read, the certificates they name are loaded, and the connector is assembled; the first socket belongs to the first request. So a host application may create a client from a thread it cannot block, and a mistyped option is reported at creation with its whole cause chain flattened into one message rather than surfacing later as a failed request. Three settings on the pool are not optional. `http2_only`, because an h2c endpoint offers no ALPN to negotiate over and would be spoken HTTP/1.1 to, while over TLS the pool would open an HTTP/1.1 connection and then a second one once ALPN answers h2. And both timers, because they are separate settings with no default and the HTTP/2 keepalive panics for want of one as soon as a connection is established. The body type is erased. A client is built before any request exists and `hyper` fixes one body type per client, so a pool that carries both a request with no payload and one that streams for minutes cannot name a single concrete one. Verified from `packages/rust`, exit code 0 for each: cargo test -p armonik-transport-ffi 51 + 6 + 8 passed cargo test -p armonik-transport-ffi --all-features 51 + 6 + 8 passed cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings cargo fmt --check cargo build --workspace --all-features cargo +nightly miri test -p armonik-transport-ffi --lib 43 passed, 8 ignored The five new tests that reach the connector are out of Miri's reach and say so: verifying against the system CAs reads the platform's certificate store, and the crypto provider runs assembly Miri cannot interpret.
`Http2KeepAliveInterval`, `Http2KeepAliveTimeout`, `Http2KeepAliveWhileIdle` and `Http2MaxHeaderListSize` reach `hyper`'s builder, and `PoolIdleTimeout` reaches the pool. The transport declares these options and leaves each HTTP/2 engine to apply them on its own side; this is that side. `PoolIdleTimeout` is in the vocabulary for a consumer that pools, and this is the first one: a channel is one connection and has none, so nothing that builds one has ever read it. An option is set only when the document names it, so an unset one keeps whatever `hyper` defaults to rather than being written over with a `None` this crate invented. `Http2KeepAliveWhileIdle` is the exception and takes no branch: the option is not optional, and `false` is what it and `hyper` both default to. `hyper` offers no way to read a setting back off a built client, so the test holds the step before it - that these spellings are the ones the configuration reads, and that they arrive as the values written - plus that a document naming all of them produces a client. Verified from `packages/rust`, exit code 0 for each: cargo test -p armonik-transport-ffi 53 + 6 + 8 passed cargo test -p armonik-transport-ffi --all-features 53 + 6 + 8 passed cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings cargo fmt --check cargo build --workspace --all-features cargo +nightly miri test -p armonik-transport-ffi --lib 44 passed, 9 ignored
`RateLimit` is written `count/duration` and means what `tower::limit::RateLimit` means: at most `count` requests admitted in any one window of `duration`, the window opening at the first request after the previous one ended. A client carries one limiter, which is what per-client means, and a client whose document names no limit carries none and pays nothing. Written here rather than taken from `tower`, which would bring in a service stack this crate has no other use for: the policy is two numbers, a deadline and a counter. Nothing about it blocks a thread. A permit is taken by awaiting, and the only wait is a `tokio::time` sleep, so an entry point never holds a host application's thread for as long as the caller's own configuration says to. The waiter holds the mutex across its sleep on purpose: that is what queues waiters instead of letting them wake together and take the same permit, and `tokio`'s mutex is fair, so the queue is the order they arrived in. Deciding and waiting are separate. `Window::take` is a pure function of the state and the clock, so the policy is tested with no runtime at all and stays runnable under Miri; the two tests that do wait use a paused clock, which asserts what the limiter waited *for* rather than how long a test took - and `tokio` only advances a paused clock once every task is parked, which is the case a limiter blocking its thread would hang in. Verified from `packages/rust`, exit code 0 for each: cargo test -p armonik-transport-ffi 63 + 6 + 8 passed cargo test -p armonik-transport-ffi --all-features 63 + 6 + 8 passed cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings cargo fmt --check cargo build --workspace --all-features cargo +nightly miri test -p armonik-transport-ffi --lib 51 passed, 12 ignored
`include/http_config.schema.json` joins the C header and the C# declarations:
the header says how to call `ak_client_create`, and this says what may be
inside the document handed to it. It is what an options class generated in
another language is built from, and a stale copy of it is an option that
silently does nothing.
Unlike the other two artefacts it is not written by `build.rs`. It comes from
the transport's own types, which a build script of this crate has no way to
reach, so it is regenerated by hand and kept current by a test that compares
byte for byte. A change to the vocabulary then shows up as a diff in a pull
request rather than as a surprise.
Compared as text rather than as parsed JSON, because what a consumer reads is
the file: a reordering that means the same thing is still a change to the
artefact. The failure names the first differing line and the command that
rewrites the file, and a second test guards the line endings the
`.gitattributes` pins, so a checkout that translated them fails for a reason it
states rather than as a mismatch nobody can see.
Verified from `packages/rust`, exit code 0 for each:
cargo test -p armonik-transport-ffi 63 + 6 + 8 + 2 passed
cargo test -p armonik-transport-ffi --all-features 63 + 6 + 8 + 2 passed
cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings
cargo fmt --check
cargo build --workspace --all-features
cargo +nightly miri test -p armonik-transport-ffi --test schema 2 passed
That the pin catches a drift rather than only passing: renaming `RateLimit` to
`RateLimitt` in the committed file fails the test with
the committed schema is out of date at line 63:
committed: "RateLimitt": {
generated: "RateLimit": {
…e other Every property the committed schema declares is named in one of two lists: `APPLIED`, with what the option reaches, or `NOT_APPLIED`, with why this layer is the wrong one to read it. An option added to the vocabulary fails this crate's build until somebody decides which it is, so no option can reach a caller's generated options class and then quietly do nothing. Only the retry schedule is left alone, and not for want of effort: a replay is a new request, and which failures are worth one is keyed on a `grpc-status` this library never sees. It moves bytes; the gRPC stack above it is what has a call to retry. The lists are checked in both directions. An entry naming nothing would go on satisfying the ledger while the option it stood for went unread, so an entry that has left the vocabulary fails too, as does one written twice or on both lists at once. Each entry carries a note, because a list of bare names would pass while telling a reviewer nothing. Verified from `packages/rust`, exit code 0 for each: cargo test -p armonik-transport-ffi 63 + 6 + 8 + 7 passed cargo test -p armonik-transport-ffi --all-features 63 + 6 + 8 + 7 passed cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings cargo fmt --check cargo build --workspace --all-features cargo +nightly miri test -p armonik-transport-ffi --test schema 7 passed That the ledger catches an addition rather than only passing: inserting a `NewlyInvented` property into the committed schema fails it with these options are in the vocabulary and in neither list: ["NewlyInvented"]. Add each to `APPLIED` with what it reaches, or to `NOT_APPLIED` with why this layer is the wrong one to read it
`Timeout`, `RateLimit` and `UserAgent` move to the not-applied list. Each is parsed and carried on the client, and nothing in this library reads any of them: nothing sends a request, so nothing times one out, takes a permit, or sets a header. A caller can set all three, get `AK_OK`, and see no request behave differently. Certifying them as applied is worse than saying nothing, because it turns "nobody has checked" into "a test says this is fine" - the one failure a ledger exists to catch. So the criterion is written down where the list is: an option is applied only if something here acts on it, and carrying a value is not acting on it. The not-applied list is now two groups and no third: the retry schedule, which belongs to the gRPC stack above this library, and the three a request applies. Whoever makes this library send a request moves the second group across, and the ledger fails until they do. There is deliberately no "pending" state - a list that is only true later is a list nobody can rely on now. `ak_client`'s doc says the same thing, in place of a field comment that claimed `UserAgent` was sent on every request. Verified from `packages/rust`, exit code 0 for each: cargo test -p armonik-transport-ffi 63 + 6 + 8 + 7 passed cargo test -p armonik-transport-ffi --all-features 0 cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings 0 cargo fmt --check 0 cargo build --workspace --all-features 0 cargo +nightly miri test -p armonik-transport-ffi --test schema 0
`ak_client_create` promises to open no connection, and reports a certificate and key that do not belong together as `AK_CONNECTION_FAILED`. The contract and the code contradict each other, and the caller pays for it: the message names two files, and the status sends whoever reads it to check network reachability. `AK_INVALID_CONFIG` is what a failure while the options are turned into a connector is. The two paths are told apart by type, not by inspection. `armonik_transport::ConnectionError` comes from `https_connector` alone, and that function assembles a connector out of certificates the configuration has already loaded and opens no socket. A failure that does reach the network arrives as the HTTP client's own error type, which nothing converts into an `FfiError` here, so `AK_CONNECTION_FAILED` stays what it says it is and keeps its meaning for whoever sends a request. The variant is renamed `Connector` to say which of the two it is. Leaving it called `Connection` while it maps to `AK_INVALID_CONFIG` is how the confusion comes back. A test states the invariant once rather than only per case: no document refused by this entry point comes back as a connection failure, so a path added later cannot quietly reintroduce one. Verified from `packages/rust`, exit code 0 for each: cargo test -p armonik-transport-ffi 64 + 6 + 8 + 7 passed cargo test -p armonik-transport-ffi --all-features 0 cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings 0 cargo fmt --check 0 cargo build --workspace --all-features 0 cargo +nightly miri test -p armonik-transport-ffi --lib 51 passed, 13 ignored
`CaCert` names no option since it became `CaCertPath`, and an option the reader does not know is ignored rather than refused. So the two tests handing it a path that does not exist read a document that asks for nothing, built a client, and asserted a configuration failure that had no reason to happen. The ledger and the three committed artefacts carry the new spelling too: the schema regenerated with the documented command, the header and the C# declarations by the build script, from the doc comment above `ak_client_create`. cargo test -p armonik-transport -p armonik-transport-ffi --all-features: 109 + 8 + 64 + 6 + 8 + 7 passed, 0 failed.
wkirschenmann
force-pushed
the
wk/feat/rust-ffi-client
branch
from
August 9, 2026 10:52
c1de125 to
88af3d7
Compare
`CaCert` became `CaCertPath`, four characters that take a test document
past the hundred-column limit. This is `cargo fmt`'s own wrapping, not a
hand-placed break: the argument alone at one more level of indent, a
trailing comma, and the closing parenthesis back at the call's indent.
Verified from `packages/rust`, exit 0 for each:
cargo fmt --check
cargo test -p armonik-transport-ffi --all-features
64 + 6 + 8 + 7 passed, 0 failed
cargo clippy -p armonik-transport-ffi --all-targets --all-features -- -D warnings
|
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.



Sits on
wk/feat/rust-transport-reexports, to merge first.Motivation
The FFI crate had the boundary's primitives and nothing that holds a connection. A host application
needs to turn a JSON document of the option vocabulary into a client it can keep.
Description
ak_client_createreads a document of the flat vocabulary, assembles the connectorarmonik-transportbuilds from it and wraps that in ahyper_utilpool;ak_client_releasegivesup the caller's reference through the registry every handle lives in. Nothing is opened - the first
socket belongs to the first request - so a mistyped option is reported at creation. Three pool
settings are not optional:
http2_only, and both timers, which have no default and without whichthe HTTP/2 keepalive panics as soon as a connection is established. The HTTP/2 and pool options
reach the builder where the document names them; a named rate limit gives that client its own
limiter. This PR builds that limiter and holds it on the client, and nothing acquires from it yet,
because nothing here sends a request: rate limiting is not in force.
include/http_config.schema.jsonjoins the two generated artefacts. It is regenerated by hand,the transport's types being out of a build script's reach, and pinned by a test comparing it byte
for byte. Every property it declares is accounted for in one of two lists: 21 applied, with
what the option reaches, and 7 not applied, with why this layer is the wrong one to read it. An
option counts as applied only if something here acts on it; carrying a value is not acting on it.
That criterion sits where the lists do: an earlier version of this PR certified
Timeout,RateLimitandUserAgentas applied while nothing read them. No pending state, no third group.A failure while the options are turned into a connector reports
AK_INVALID_CONFIG, notAK_CONNECTION_FAILED. The two are separated by type rather than by inspection:ConnectionErrorhas exactly one producer,
https_connector, which opens no socket.Testing
Adds 32, counted on this branch rather than as a suite total: the lib goes from 39 to 64, and a
schematarget of 7 is new. One states the invariant once rather than per case - no documentrefused by
ak_client_createcomes back asAK_CONNECTION_FAILED, across six failing documents.cargo test -p armonik-transport-ffi, and the same under Miri bar the tests reaching the platform'scertificate store or the crypto provider.
Impact
New entry points, with the header and bindings regenerated, and the error variant renamed
Connectorto say which of the two failures it is.
publish = falsestill holds.Additional Information
The retry schedule stays unapplied on purpose: a replay is a new request, keyed on a
grpc-statusthis library never sees. The three request-applied options cross over when something here sends one.
Checklist