Skip to content

feat(rust): give the C ABI a client over the transport's connector - #747

Draft
wkirschenmann wants to merge 9 commits into
wk/feat/rust-transport-reexportsfrom
wk/feat/rust-ffi-client
Draft

wkirschenmann wants to merge 9 commits into
wk/feat/rust-transport-reexportsfrom
wk/feat/rust-ffi-client

Conversation

@wkirschenmann

@wkirschenmann wkirschenmann commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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_create reads a document of the flat vocabulary, 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 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 which
the 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.json joins 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,
RateLimit and UserAgent as 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, not
AK_CONNECTION_FAILED. The two are separated by type rather than by inspection: ConnectionError
has 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
schema target of 7 is new. One states the invariant once rather than per case - no document
refused by ak_client_create comes back as AK_CONNECTION_FAILED, across six failing documents.
cargo test -p armonik-transport-ffi, and the same under Miri bar the tests reaching the platform's
certificate store or the crypto provider.

Impact

New entry points, with the header and bindings regenerated, and the error variant renamed Connector
to say which of the two failures it is. publish = false still holds.

Additional Information

The retry schedule stays unapplied on purpose: a replay is a new request, keyed on a grpc-status
this library never sees. The three request-applied options cross over when something here sends one.

Checklist

  • My code adheres to the coding and style guidelines of the project.
  • I have performed a self-review of my code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation.
  • I have thoroughly tested my modifications and added tests when necessary.
  • Tests pass locally and in the CI. (locally and under Miri; CI has not run this branch)
  • I have assessed the performance impact of my modifications. (nothing measured)

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
1478 1247 84% 0% 🟢

New Files

No new covered files...

Modified Files

No covered modified files...

updated for commit: e1b66a9 by action🐍

@wkirschenmann
wkirschenmann force-pushed the wk/feat/rust-ffi-client branch from 6bec3e9 to ab40475 Compare August 9, 2026 08:37
@wkirschenmann
wkirschenmann force-pushed the wk/feat/rust-ffi-client branch from ab40475 to c1de125 Compare August 9, 2026 10:40
`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
wkirschenmann force-pushed the wk/feat/rust-ffi-client branch from c1de125 to 88af3d7 Compare August 9, 2026 10:52
`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
@sonarqubecloud

sonarqubecloud Bot commented Aug 9, 2026

Copy link
Copy Markdown

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