Repository navigation
v0.4.0
Changed
-
Releases are version-driven (see the note above):
release.ymlno longer
tags every merge; it publishes to crates.io, then tagsvX.Y.Z, only when a
release: X.Y.ZPR changes the workspace version, and only after Tests
pass. Newscripts/bump-version.sh;publish-crates.sh --yesfor CI. -
ErrorHandlingplugin removed (PR #68/#69, task #1025). Panic capture is
now part of router assembly, so the plugin was a third copy of the layer that
never fired for a handler panic. Breaking:.plugin(ErrorHandling)and
catch_panic_layer()no longer compile — delete the line, nothing replaces it. -
Plugin routes mount in the Routes stage (PR #69). Prometheus
/metrics,
ther2e-oidcendpoints andEmbeddedFrontend's SPA fallback go through
after_routesinstead of anadd_layerclosure, so they now sit inside
HttpTrace, the metrics layer and the catch-panic slot (static responses are
traced and counted;EmbeddedFrontendno longer has to be installed last).
Rule:add_layerwraps, it never mounts. Breaking:EmbeddedFrontend
combined with a controller#[fallback]is now a boot panic instead of a
silent override. -
MCP session binding (PR #74). Sessions are bound to their principal: a
Mcp-Session-Idreplayed by another subject gets HTTP 404 (was JSON-RPC
-32600). Breaking:ToolRoute/ResourceRoute/PromptRoutegain
group(pluscompletionson resources and prompts),McpRoutesgains
uses_session,ToolCall/ResourceCall/PromptCallgainsessionand
progress(use the new::newconstructors),McpServer::Providedgains
McpSessions,McpSessionErrorgainsInvalidCompletion.McpSession
undermcp.statelesspanics at boot.
Added
-
attach_reuseport_cbpfingress helper (PR #75): installs a classic-BPF
steering program on aSO_REUSEPORTgroup (SO_ATTACH_REUSEPORT_CBPF,
Linux) — the program returns the target socket's group index, i.e. its bind
order — withCbpfInsnin the kernelsock_filterlayout. Breaking:
newAffinityError::ReuseportFilterUnsupportedvariant (returned off Linux). -
Observable panics and a unified
on_panichook (PR #68/#70/#71, tasks
#1017/#1027). R2E's own catch-panic layer, installed innermost (so a handler
panic still gets therequest completedline, the 5xx metric and
x-request-id) and outermost as a last-resort net, emits one
tracing::error!on targetr2e::panicwith the message and route.
AppBuilder::on_panic(|report| ..)is called once per panic from HTTP
handlers,#[scheduled]ticks (shared or dedicated pool, tick factories
included) andPoolExecutorjobs;PanicReport::origin()returns a
PanicOrigin::{Http, Scheduled, Executor}andlabel()a bounded metric
label. A panicking hook is contained. NewPoolExecutor::submit_named;
#[async_exec]jobs are named after their method. Breaking: hooks that
assumed HTTP-only now also see background panics. -
MCP per-session member lists and protocol gaps (PR #74).
#[mcp_routes(group = "…", opt_in)]groups; anMcpSessionmember parameter
enables/disables groups and adds session-privateDynamicTool/
DynamicResource/DynamicPromptmembers (withlist_changed
notifications);McpServer::session_init::<T: McpSessionInit>()and the
McpSessionsbean. Protocol:Progressparameter
(notifications/progress), live elicitation viaMcpClient<'_>
(elicit::<T>(),elicit_url(),mcp.elicitation-timeout-secs),
#[completion]providers wired withcomplete(arg = "fn")and checked at
compile time, and*/listpagination (mcp.page-size) with cursors bound
to the caller's visible list. -
Per-request span enrichment channel (task #1015): the
HttpTracelayer
now publishes the request span as theRequestSpanrequest extension —
handlers take it as a parameter andrecord(..)domain fields their
MakeRequestSpandeclaredEmpty(session_id,tenant_id, …), at any
call depth and without task-local plumbing (excluded routes yield a no-op
Span::none()). A new defaultedMakeRequestSpan::make_stateallocates an
optional per-requestSpanStateslot (type-erasedArc), published as a
request extension and handed back toon_response— the way values written
during the request reach a custom summary event, since span fields are
write-only. Breaking:MakeRequestSpan::on_responsegains a
state: Option<&SpanState>parameter (default impl unchanged otherwise). -
TestAppcan reuse anApp::Envacross boots (task #988): three new
boots skipA::setup()and build on an environment the caller already owns —
TestApp::boot_env::<A>(env),TestApp::boot_with_env::<A>(env, configure),
TestApp::boot_plain_env::<A>(env, configure), plustry_boot_env/
try_boot_with_env/try_boot_plain_env.App::Envis already
Clone + Send + Sync + 'static, so a test binary builds the expensive part
once and boots every test off it instead of replaying pools and migrations
per test (#[before_all]only amortises inside one suite).use r2e_test::SharedEnv; static ENV: SharedEnv<MyApp> = SharedEnv::new(); #[r2e::test(app = my_app::MyApp, env = ENV.get().await)] async fn lists_users(app: TestApp) { app.get("/users").send().await.assert_ok(); }
r2e_test::SharedEnv<A>is the supported way to memoise it:new()/
with(init)areconst(so they go into astatic),get().await/
try_get().awaitbuild the environment once per process on a runtime
r2e-testowns and never shuts down, and concurrent first callers share the
one run. A bareOnceCell/LazyLockmust not be used:#[r2e::test]builds
one runtime per test and drops it at the end of the test, so an environment
initialised there keeps its value but loses its reactor (listeners, pool
keep-alive tasks, timers, anythingsetupspawned), and later tests hang on
an inert environment.#[r2e::test]and#[r2e::test_suite]gained the matchingenv = <expr>
knob (evaluated inside the test's async block, composes withwith = …and
jwt = false, requiresapp = …; on a suite it also requires a
#[before_all]that binds the booted app, since that hook is what evaluates
it — likewise forwith = …/jwt = …). Everything else is unchanged:
testprofile, pinnedTestJwtvalidators, the production startup phase,
shutdown(). Isolation is the caller's job — a sharedEnvis shared
state across concurrently running tests. The harness never disposes theEnv
itself, butshutdown()does run whateverA::buildregistered, so an app
that hands anEnv-owned resource to a disposer still invalidates it for
later boots. -
Feature modules own their gRPC services (task #989):
#[module]gained a
grpc_services(...)key, the transport peer ofcontrollers(...). A vertical
slice now declares its gRPC service next to its HTTP controllers, and that
service may inject the module's private providers — which the app-level
.register_grpc_service::<S>()cannot, since it checks the service's deps
against the application state (so a module bean had to be exported to be
injectable).#[module(providers(GreetingRepo), grpc_services(GreeterService))] // GreetingRepo stays private pub struct GreetingModule; AppBuilder::new() .plugin(GrpcServer::on_port("0.0.0.0:50051")) .register_module::<GreetingModule>() // no .register_grpc_service::<_>() needed .build_state() .await
The services are dependency-checked module-locally at
register_module
(deps ⊆ providers ∪ imports,#[intercept(...)]spec deps folded in, exactly
likeM::Controllers) and registered bybuild_state()from the module's
retainedBeanContext, in declaration order after the module's controllers.
The key also implies theGrpcServerplugin: the macro appends it to the
module'sRequiredPlugins, so forgetting.plugin(GrpcServer::...)is a
compile error namingGrpcServerrather than a service silently
registered into a registry nobody drains. A module may equally bring the
plugin itself withplugins(GrpcServer = GrpcServer::on_port(..)).
RequiredPluginsis verified by checking the plugin's provisions against
the provision list, soGrpcMarker— whatGrpcServerprovides — is now
unconstructible outside r2e-grpc: nobody can hand-.provide(..)it to make a
module compile without the plugin (and hence without the registry the module
registers into). A hand-writtenimpl FeatureModulethat skips
RequiredPluginsaltogether still fails at boot, with
BeanError::MissingTransportPluginnaming both the plugin and the module.Each service is registered once: a name already in the registry — the same
service in two modules'grpc_services(..), or in a module and an app-level
.register_grpc_service::<S>()— fails instead of handing tonic two
overlapping route sets under one name. The module path reports
BeanError::DuplicateEndpointon thetry_build_state()channel; the
app-level call panics (that call site already panics for a missing plugin);
a service listed twice in onegrpc_services(..)is a macro compile error.r2e-core stays transport-agnostic:
FeatureModulegained
type Endpoints: ModuleEndpointSet(type-levelDepsonly) and the
value-levelModuleEndpoints<T>registration hook, both implemented in
r2e-grpc for the newr2e_grpc::ModuleGrpcServices<(A, B)>(what the macro
generates). Modules withoutgrpc_services(..)emittype Endpoints = ();
and no r2e-grpc path, so they still compile in apps without the gRPC feature.BREAKING (pre-production, no compatibility shim):
- hand-written
impl FeatureModuleblocks must addtype Endpoints = ();
(stable Rust has no associated-type defaults) — the#[module]macro
generates it; RegisterModulegained a witness type parameter (EndpIdx) — only visible
to code that names the trait's parameters explicitly;- three new
BeanErrorvariants,EndpointConfig,MissingTransportPlugin
andDuplicateEndpoint— an exhaustivematchonBeanErrormust add arms; GrpcServiceRegistry::add_servicereturnsResult<(), DuplicateService>
(it was()), andGrpcMarkeris no longer constructible outside
r2e-grpc;- registering the same gRPC service twice now fails (module: boot error;
app-level.register_grpc_service::<S>(): panic) instead of silently
double-registering it.
- hand-written
-
rt::RuntimeId(Runtime::id()/RuntimeHandle::id()): the identity of
a runtime, comparable across threads, for asserting that two pieces of work
share one reactor. Used by#[r2e::test_suite]'s guard-rail (see Fixed).
Ids are unique only among live runtimes: once a runtime is dropped its id
may be reused by a later, unrelated one, so an id only proves shared identity
when compared against a runtime known to be alive (which is how the suite
guard uses it — the suite owns the runtime it names). -
rt::Runtime::shutdown_timeout/shutdown_background: shut a runtime
down explicitly, for runtimes parked in astaticthat can never be dropped
by going out of scope.#[r2e::test_suite]uses the first one at teardown. -
Worker scopes and verifiable multi-worker serving (task #990, ADR
docs/adr/0001-worker-scopes-and-planes.md):WorkerInfo(stable worker
identity — id / count / role / effective CPU — readable anywhere, incl. as a
handler parameter),WorkerLocal<T>+AppBuilder::worker_local(exactly one
!Send-capableTper worker, built/used/dropped on its worker thread),
WorkerSet+WorkerState+WorkerHealth(aggregated per-worker lifecycle
and errors, health indicator),Mailboxes<M>(counted cross-worker messaging
withsend_to/broadcast/ask_all),r2e::runtime::ingress
(reuseport_tcp/reuseport_udp/adopt_*,AffinityError::Unsupported— no
silent fallback),WorkerCollectorinr2e-prometheus(r2e_worker_*
series), andWorkerHarnessfor deterministic tests. Docs in
docs/features/19-sharded-serving.md; exampleexamples/example-worker-udp
rewritten as a shared-nothing service with control-plane aggregation. -
grpc-web on the multiplexed gRPC transport (feature
grpc-web,webon
r2e-grpc):GrpcServer::multiplexed().with_grpc_web()(or
.with_grpc_web_cors(CorsLayer)) adds atonic-webarm to
MultiplexServiceforapplication/grpc-web,grpc-web+protoand
grpc-web-textrequests over HTTP/1.1 and HTTP/2, with CORS preflight
handling. Without it grpc-web requests still get415+ a boot warning. -
r2e::http::IntoHttpResponse— R2E's own response-conversion contract,
the counterpart ofFromRequestPartsViaon the extract side. R2E error types
(HttpError,ParamError,MultipartError,RequestId,SecurityError,
TenantError,OidcError) and everything#[derive(ApiError)]generates now
implement this trait instead of the HTTP backend'sIntoResponse, and
bridge to the backend through a single macro:impl IntoHttpResponse for MyError { fn into_http_response(self) -> Response { /* … */ } } r2e::http::impl_into_response!(MyError);
Not a break: the bridge emits the backend impl, so every type that was
returnable from a handler still is, andResult<T, E>/(StatusCode, T)
composition is unchanged. A hand-writtenimpl IntoResponse for MyErroralso
keeps working —IntoHttpResponseis the recommended way, not the only one.
The macro is a macro rather than a blanket impl because
impl<T: IntoHttpResponse> IntoResponse for Tis an orphan impl and the
mirror blanket would forbid all per-type impls; seer2e-http/src/response.rs.
IntoHttpResponseis in the prelude. -
r2e::http::axum_compat— the explicit escape hatch to the rawaxum
API (use r2e::http::axum_compat::axum;), for the cases a re-export shim
cannot cover: tower layers with axum-typed bounds,axum::debug_handler,
third-party crates whose API is spelled in axum types. This settles §5.3d of
plans/runtime-http-dependency-containment.mdas decision A: R2E's public
promise is R2E types underr2e::http/r2e::preludeplus R2E's own
contracts (IntoHttpResponse,FromRequestPartsVia); axum stays reachable,
but only through a name you have to type on purpose. Apps should still not
addaxumto their ownCargo.toml. -
New crate
r2e-rt— the async-runtime facade, sitting at the bottom
of the workspace dependency graph (belowr2e-http). It is now the single
workspace member allowed to nametokio/tokio-util/tokio-stream
directly, so swapping the runtime — or moving further towards thread-per-core
sharded runtimes — is a change in one crate instead of a hunt across dozens of
call sites. Two enforcement scripts freeze the boundary
(scripts/check-dep-boundary.sh,scripts/check-source-boundary.sh).
r2e-core/src/rt.rsmoved into it wholesale andr2e_core::rtis now a
re-export, sor2e::rt::…/r2e_core::rt::…keep resolving to exactly
what they always did (spawn,spawn_ctl,spawn_blocking,JobHandle,
sleep,timeout,interval,bind_tcp,shutdown_signal, …).
New in the facade, on top of the moved surface:rt::CancelToken/rt::CancelDropGuard— wrappers over
tokio_util::sync::CancellationToken/DropGuard, so an app can consume
R2E's shutdown API without addingtokio-utilto its ownCargo.toml.
Fromconversions both ways keep the not-yet-migrated crates working.rt::sync— re-exports ofmpsc,oneshot,broadcast,watch,
Mutex,RwLock,Notify,Semaphore,OnceCell.rt::{select!, pin!, join!},rt::JoinSet,rt::stream,
rt::{RuntimeBuilder, Runtime, block_on}.rt::Instant+rt::sleep_until(deadline)— the deadline form of
rt::sleep, on the runtime's own monotonic clock; what a timer wheel driven
by absolute fire times needs (the scheduler's min-heap driver).rt::yield_now()andrt::in_runtime()— the latter is the non-panicking
probe behindcurrent_handle, for synchronous paths that may run outside a
runtime (aDropimpl detaching cleanup work).- A non-default
test-utilfeature (tokio/test-util), off by default
because paused clocks must not reach the whole workspace through feature
unification. rt::TcpStreamand thert::iomodule (AsyncRead/AsyncWriteand
their…Exttraits,BufReader,BufWriter,duplex) — re-exports, the
same treatment asrt::TcpListenerandrt::sync. They are what raw-socket
test code and byte-stream plumbing need, and their absence was the last
reason to keep a directtokiodependency around.rt::stream::wrappers
also carriesTcpListenerStreamnow (tokio-stream'snetfeature).
Fixed
-
#[derive(ConfigProperties)]garde rules now run (PR #73, task #1046).
The derive looked for#[validate(..)]instead of#[garde(..)], so
validation was never emitted. It now runs after construction and reports
ConfigError::Validationwith dotted keys (app.pool.size). Behavior
change: a violated rule now failsfrom_config, and therefore boot. -
Executor drain no longer stalls on a panicking job (PR #70): panics are
caught at poll level, so permit release and drain/completed counts survive
andshutdown_gracefulcompletes. -
r2e::preludeno longer ambiguous with both data backends enabled
(task #1016). The prelude glob-re-exportedr2e_data_sqlx::prelude::*and
r2e_data_diesel::prelude::*unconditionally, and the two backends
deliberately export mirrored names (DbPool,DbTx,Tx,
DataSourceHealth,TenantPools,TenantTx) — so any build enabling both
data-sqlxanddata-diesel(a dual-backend app, or a workspace sibling
pulling the other backend through cargo feature unification) made every use
of a mirrored name a deny-by-defaultambiguous_glob_importserror. With
exactly one backend enabled its prelude joinsr2e::preludeas before; with
both, only the backend-unique names (SqlxDataSource,SqlxTx,
DieselDataSource,DieselTx) remain and the mirrored ones are imported
explicitly fromr2e::r2e_data_sqlx/r2e::r2e_data_diesel(an explicit
import shadows the glob, so it is stable under both modes). -
#[sse]/#[ws]routes publish their parameters in the OpenAPI spec
(task #1013, follow-up of #1009). Streaming metadata hardcoded
params: vec![], so a#[derive(Params)]argument — which the generated
handler really does extract from the request — never appeared in
/openapi.json, and neither did aPath<T>argument. Both route kinds now
build theirRouteInfo.paramsthrough the same code path as a verb route
(Path(name): Path<T>literals + theParamsMetadataautoref probe, then
deduplicated), so moving a documented#[get]to#[sse]keeps its
parameters as well as its prose. A WebSocket method'sWsStream/WebSocket
argument comes from the upgrade rather than an extractor and is excluded. -
r2e-core'sruntimetest target is green under--features dev-reload
again (task #995). Two independent causes, neither of which CI saw (no
workflow runs thedev-reloadfeature). (1) The builder-level per-worker
service test served a sharded app, butdev-reloaddeliberately forces the
single cached-listener path, sorun()rejects the registration by design;
the test is now compiled out under the feature and replaced by one that
asserts the rejection. (2) The dev-reload hot-patch tests shared a process
with the ordinary serving tests.mark_hot_reload_loop()is process-global
and one-way, so once a dev test had armed it the next served app set
LIFECYCLE_INITIALIZED— after which every laterrun()in the binary
skipped consumers, serve hooks and startup hooks and quietly lost its
spawn_servicetasks (shutdown_budget::grace_period_bounds_a_stubborn_service_and_names_it
was the visible casualty). No lock can fix that across parallel test threads,
so the dev-reload tests now live in their own target,
r2e-core/tests/dev_reload/, and theruntimetarget no longer needs the
dev_seriallock at all. -
The
dev-reloadper-worker-service error no longer gives impossible
advice. It used to be built fromPER_WORKER_REQUIRES_SHARDING_MSG, so it
told you to setserver.workers— a keydev-reloadignores. It now states
that the feature forces single-listener serving and that per-worker services
require a build withoutdev-reload(and a platform with SO_REUSEPORT
sharding — dropping the feature is necessary, not sufficient). -
Attribute macros no longer drop the attributes you write (task #985).
Several attribute macros rebuild the item they annotate from its pieces
(visibility + signature parts + body) so they can strip R2E's own parameter
and field attributes. Those rebuilds silently discarded everything else.#[producer]dropped the wholeattrslist of the annotated function:
#[allow]/#[deny],#[inline],#[deprecated],#[must_use]and doc
comments written on a producer did nothing. It also droppedconstand
extern "…"from the signature. All of them are forwarded now, and the
generated bean struct carries a doc comment of its own so
#![deny(missing_docs)]crates keep building.#[deprecated]warns at a
direct call to the function; the generated struct is a separate item and is
not itself deprecated, so.register::<CreatePool>()stays quiet.#[routes]dropped the attributes on theimplblock (a#[allow(...)]
or doc comment aboveimpl MyControllervanished) and dropped every
associated item that was neither a route, a#[consumer], a#[scheduled]
nor a lifecycle hook — an associatedconst, an associatedtypeor a
plain helperfnwritten in a#[routes]block disappeared from the
build. Impl attributes now reach both synthesized impls, and the other
items stay on the controller core. Note that a route body'sSelfis the
request façade, so reach an associated const through the controller name
(MyController::PAGE_SIZE). Because there are two synthesized impls,
only inert attributes may sit below#[routes]— doc comments,
#[allow]/#[warn]/#[deny]/#[expect]/#[forbid],#[deprecated],
#[cfg],#[cfg_attr]and tool attributes (#[rustfmt::skip]). Anything
else (an attribute macro) would expand once per impl, so it is a compile
error pointing at the position where it runs exactly once: above
#[routes].#[bean]dropped the attributes and theconst/externpieces of the
constructor it re-emits.#[async_exec]dropped parameter attributes (#[cfg]on a parameter,
#[allow]) when re-emitting the wrapper's parameter list. A parameter
#[cfg]is now forwarded to the forwarding call as well, so a gated-out
parameter disappears from the signature and the call together instead of
leaving the disabled build with an unbound argument.#[controller]projects a request-scoped field's attributes onto the
generated request extractor and façade, and the generated code that binds
them carries#[allow(deprecated, non_snake_case)]: a#[deprecated]
request field warns where you read it, not from inside framework code, so
a crate under#![deny(deprecated)]still builds.
Because rustc evaluates an item-level
#[cfg](and a#[cfg_attr]expanding
to one) before it invokes an attribute macro — in either attribute order —
a#[cfg]'d-out producer, controller,#[routes]impl or bean never reaches
the macro at all, and no generated impl is left dangling. That is pinned by
tests rather than assumed (r2e-core/tests/di/producer_attrs.rsand
r2e-core/tests/controller/attrs.rs).One signature piece is rejected rather than forwarded (breaking, but no
such code compiled before either): anunsafe fn#[producer]or#[bean]
constructor. R2E generates a safeProducer::produce/Bean::buildthat
is the only caller, and the bean graph cannot discharge anunsafecontract
it knows nothing about — re-emitting the signature verbatim is an E0133, and
adding anunsafe { }block around the generated call would sign the
contract on the user's behalf. Dropunsafefrom the signature and keep the
unsafe { }block, with its SAFETY comment, inside the body. -
#[producer]now emits#[allow(clippy::too_many_arguments)]on the
function and on the generatedProducerimpl. A producer takes one parameter
per dependency, so clippy's 7-argument threshold fires on perfectly
idiomatic producers and (before the fix above) could not even be silenced.
User attributes are emitted after it, so#[warn(clippy::too_many_arguments)]
on the function opts back in. -
#[r2e::test_suite]now builds ONE runtime per suite, not one per#[case]
(task #986). The suite value lives in a module-levelOnceLockthat outlives
every case, but each generated#[test]used to build — and then drop — its
own runtime. Anything#[before_all]amortised that is bound to a reactor (a
TestApp, asqlxpool, a socket, a spawned task, a timer) went inert after
case 1; because such a resource stops waking rather than erroring, the suite
failed far from the cause, typically asPoolTimedOut. The runtime is now
owned bySuiteCellin that sameOnceLockand is never dropped, so
#[before_all],#[before_each], every case,#[after_each]and
#[after_all]share one reactor.#[case(order = N)]and the per-case libtest
#[test]are unchanged; the runtime knobs (flavor,worker_threads,
start_paused, …) stay on#[r2e::test_suite(...)]and now configure that
single runtime — notestart_pausedmeans one paused clock for the whole
suite instead of a fresh one per case. Guard-rail: every phase
(#[before_all], each case,#[after_each],#[after_all]) asserts from
inside itsblock_onthat it is on the suite runtime and panics naming both
runtimes if not.Teardown: the last case to finish runs
#[after_all], then drops the suite
value inside the runtime (so a socket or pool still has its driver in
Drop) and shuts the runtime down with a one-second grace for blocking work.
Without that the suite's worker threads and detached tasks would outlive it
for the rest of the test process, since theOnceLockis never dropped.
Anything reaching the suite after teardown panics by name instead of hanging."Last case" is counted against the number of generated
#[case]s, because
libtest does not expose which tests the process actually selected. So a
filtered run (cargo test some_case) runs#[before_all]and the case but
never#[after_all]— the suite value is leaked to process exit, as before.
For the same reason#[ignore]on a#[case]is now a compile error:
it would either suppress teardown entirely or let teardown fire before the
ignored case runs. Skip inside the case body instead.Runtime knobs that make the builder panic rather than return an error
(start_pausedwithoutflavor = "current_thread", a zeroworker_threads
/max_blocking_threads/global_queue_interval/event_interval, a blank
thread_name) are now rejected at macro expansion with a spanned compile
error, on#[r2e::main]/#[r2e::test]/#[r2e::test_suite]alike; any
remaining builder panic is caught and re-raised naming the suite or test that
asked for it.
Changed
-
r2e-openfgagenerates its own gRPC client —vendor/openfga-rsand the
workspace[patch.crates-io]that reached it are gone. A[patch]is
workspace-local and never travels with a published crate, which is what kept
r2e-openfga,r2e-openfga-macrosandr2e-openfga-modeloff crates.io and
forced the facade'sopenfgafeature to stay commented out; all four are
released again. The protos live inr2e-openfga/proto/and the client is
generated byr2e-openfga/codegenintor2e-openfga/src/proto/openfga.v1.rs,
which is committed: abuild.rswould makeprotoca hard build
requirement for every consumer enabling the feature, even though such a
consumer never authors a proto.scripts/generate-openfga-proto.shis the only
thing that needs protoc, and CI runs it with--checkso the file cannot drift
from the schema. Client only — R2E consumes OpenFGA, it never serves it.Breaking:
r2e_openfga::openfga_rsis removed. The wire types and the gRPC
client arer2e_openfga::proto::*, withtonicandprost_typesre-exported
from the crate root so a caller building a raw request needs no extra manifest
entry. The derived serde on the wire types goes with it: prost tags oneof
variants by Rust variant name, which never matched OpenFGA's JSON, so
model_convert.rswas already converting the AST by hand. -
Release tags now follow the workspace version (task #1011). The release
workflow no longer bumps the patch of whatever the latest tag was: it reads
versionfrom the rootCargo.toml, tags in thatvX.Y.*series (patch =
release counter), and refuses member crates that don't use
version.workspace = true. First aligned tag:v0.3.0. The tag ↔ version
correspondence for the pre-alignment series is documented at the top of this
file. -
Perf: one
AuthenticatedUserper authenticated MCP request (task #993).
The auth layer deposited the caller twice — standalone for the identity
extractor, and insideMcpPrincipal— so every request deep-copied the
claims tree, flattenedextramap included, and the cost grew with whatever
the IdP put in the token. Breaking:McpPrincipal.useris now an
Arc<AuthenticatedUser>. Reads are unchanged (principal.user.subgoes
throughDeref); an owned copy is(*principal.user).clone(). The layer
depositsArc::clone(&principal.user)as the identity extension, and the
#[tool]/#[resource]/#[prompt]codegen now resolves an identity
parameter through the newToolCall::identity::<T>()(also onResourceCall
/PromptCall), which looks forArc<T>first and materializes the owned
Tonly for a member that actually declares one — a member taking
ToolCallcan readcall.extension::<Arc<AuthenticatedUser>>()and copy
nothing. An authenticatedtools/callwith a 32 KiB claims tree went from
764 allocations / 129,176 B per request to 200 / 32,976 B — the same
cost as a caller with no extra claims at all — andMcpPrincipal::clone
(layer, opaque-token cache,check_access) from 282 allocations / 47,300 B
to 0. Guarded byr2e-mcp/tests/hotpath/principal.rsand
r2e-mcp/tests/auth/identity.rs; numbers in
docs/claude/hot-path-clone-audit.md. -
Perf: MCP
tools/listno longer re-allocates the tool metadata
(task #994). Everytools/listclones the wire payload built at boot (and
everytools/callclones one element of it), but rmcp'sToolstores
name/descriptionasCow<'static, str>whileToolRoute::description
was anOption<String>— so each clone re-allocated a string#[tool]had
emitted as a literal.ToolRoute::descriptionis now
Option<Cow<'static, str>>and the macro emitsCow::Borrowed: a six-tool
tools/listclone went from 7 allocations / 1080 B to 1 allocation /
1056 B (the destinationVecalone), and stays there with long
descriptions where the old path cost 2748 B. Breaking for hand-built
ToolRoutes only:description: Some(s.into())wheres: String,
Some("…".into())for a literal — the macro path and the wire format are
unchanged (pinned byr2e-mcp/tests/server/wire_golden.rs).Resource,
ResourceTemplateandPromptareString-typed in rmcp itself, so their
lists still copy their strings; both R2E-side halves (built once at boot,
visibility filter applied before cloning) were already in place. Guarded by
r2e-mcp/tests/hotpath/lists.rs; numbers in
docs/claude/hot-path-clone-audit.md. -
Perf: the router state is one
Arc(task #992). The HTTP backend clones
the router state on every request, whether or not a handler asks for it, so
installing the resolved bean HList directly meant one beanCloneper bean
per request — O(N) in the width of the graph, and a deep copy for any bean
that owns its data.build_state()now wraps the materialized list in the new
r2e::BeanState<L>(the list behind a singleArc), so the per-request state
clone costs O(1) at any graph size — the backend takes two state clones per
request, and each is now a refcount bump rather than one clone per bean:
measured on a 64-bean state, 128 bean clones per request → 0, and for beans
owning aString, 142 allocations / 10389 B per request → 14 / 1053 B (the
same as at 8 beans).state.get::<T>(),state.bean::<T>(),HasBeanindex
witnesses,Contains/AllSatisfiedbounds andFromRequestPartsViaare all
forwarded through the wrapper, each keeping the cost it already had —
geta fixed-offset field read (now behind one dereference),beanthe
same runtimeTypeIdwalk it always was — and no application code,
controller, plugin or macro changes. Mildly breaking:
the state type is nowBeanState<HCons<…>>, so code that spells the state
type out (a hand-writtenAppBuilder<HCons<A, HNil>>annotation, a
hand-assembled test state) must wrap it —BeanState::new(list). Guarded by
examples/example-app/tests/hotpath/state.rs; numbers in
docs/claude/hot-path-clone-audit.md. -
BREAKING (
r2e-macros):#[cfg]/#[cfg_attr]on a request-scoped
controller field (#[inject(identity)]/#[inject(request)]) is now a
compile error instead of a silent no-op (task #985). Those fields are
projected into a positional marker tuple on the generated request extractor,
which cannot be gated element-wise; conditionally compiling one used to
produce a mismatched extractor rather than the field the author asked for.
#[cfg]the whole controller instead. App-scoped#[inject]/#[config]
fields are unaffected. -
BREAKING (
r2e-macros): a plain#[r2e::test]with parameters is now a
compile error naming#[r2e::test(app = MyApp)](task #985). Parameters are
bound from the bootedTestApp; without anapp = …there is nothing to
bind them from, and the generated#[test]fn used to fail with a confusing
libtest signature error. -
Perf (no API change): constant error bodies are no longer built through
serde_json::json!on every response.SecurityError(401/503), the panic
handler's 500, and the rate limiter's 429 / 401 now return a pre-serialized
&'static strbody via the newr2e::http::response::static_json(status, body)helper —Bytes::from_static, so noValuemap allocation and no
serializer pass per rejection. This is the hot path under unauthenticated or
throttled traffic. Response bodies are byte-identical. Dynamic messages
(ParamError,MultipartError,HttpError::from_status) keep going through
Json/json!, which escapes interpolated values correctly. -
BREAKING (
r2e-core): the shutdown-token surface now hands out
r2e::rt::CancelTokeninstead oftokio_util::sync::CancellationToken—
ServeContext::shutdown_token(),ConfigWatchContext::{new, shutdown_token}
andLiveConfigReceiver::drive. Call sites that onlyselect!on the token
or pass it along are unaffected; a site that needs the raw tokio-util token
(tonic'scancelled_owned(), say) converts with.into()/.into_inner(). -
BREAKING (
r2e-events,r2e-scheduler): the same flip reaches the
event-bus and scheduler surfaces, which now speakr2e::rt::CancelToken:
BackendState::{poller_cancels, register_poller_cancel}and
reconnect_loop(…, cancel: &CancelToken, …)inr2e-events;
SchedulerHandle::{new, channel, token},jobs_driver,start_jobsand the
CancelTokenbean theSchedulerplugin provides (an app injecting the
scheduler token writes#[inject] cancel: CancelTokennow) in
r2e-scheduler.Fromconverts both ways with
tokio_util::sync::CancellationToken, so a call site that needs the raw token
adds.into(). -
BREAKING (
r2e-core):ServiceComponent::startnow takes
r2e::rt::CancelTokeninstead oftokio_util::sync::CancellationToken.
Hand-written background services update their signature (async fn start(self, shutdown: CancelToken));#[derive(BackgroundService)]users update the
runmethod it delegates to (async fn run(&self, shutdown: CancelToken)).
With that flipr2e-tenant,r2e-data-sqlxandr2e-data-dieseldropped
their lasttokio-utildependency. -
r2e-coreno longer depends ontokio/tokio-util/tokio-streamat
all (dev-dependencies aside): every internal call site — the builder and
prepared-server paths, sharded serving, lazy-bean resolution, live-config
watching, health, SSE/WS, dev-reload — goes throughr2e_core::rt. Sharded
serving in particular is now expressible on the facade thanks to two
additions:rt::RuntimeHandle(a wrapper overtokio::runtime::Handle, now
the type ofrt::current_handle,rt::control_plane_handle,
rt::set_control_planeandRuntime::handle) andrt::TcpListener
(re-exported, since axum'sservetakes the concrete type). Also new:
rt::block_in_placeandCancelToken::cancelled_owned. -
#[r2e::main]/#[r2e::test]/#[r2e::test_suite]and
#[derive(BackgroundService)]now emit facade paths — the runtime is built
through<crate root>::rt::RuntimeBuilderand the service token is
<crate root>::rt::CancelToken, resolved through the samer2e/
r2e_coreroot every other emitted path uses. A generated project no
longer needstokioin itsCargo.toml(r2e newstopped emitting it).
start_paused = trueneeds the paused clock, now behind a forwarded feature:
r2e/test-util→r2e-core/test-util→r2e-rt/test-util, whichr2e-test
turns on so it is present in any crate's dev graph and absent from release
builds. -
clippy.tomlgrew adisallowed-typeslist —
tokio_util::sync::CancellationToken,tokio::task::JoinHandle,
tokio::runtime::Handle— next to the existingdisallowed-methodsdeny on
raw spawns. Runtime-neutral primitives (tokio::sync::*,Instant,
JoinSet, …) stay allowed: they are re-exported by identity. The only
exemptions are the#[expect]-marked wrapper definitions inr2e-rt. -
r2e-events(+ theiggy/kafka/pulsar/rabbitmqbackends),
r2e-scheduler,r2e-executorandr2e-tenantnow go through thert
facade for spawning, timers, sync primitives andselect!, and dropped
their directtokio/tokio-util/tokio-streamdependencies. No
behaviour change; the four distributed backends needed no client-API escape
hatch. -
r2e-httpre-sources the neutral HTTP types from thehttpcrate —
StatusCode,HeaderMap,HeaderName,HeaderValue,Method,Uri,
Partsand the header constants now come fromhttp::…instead of
axum::http::…, andExtensions/Urilikewise. No type changes: axum
re-exports those very types fromhttp, and the workspace resolves a single
httpversion, so this is identity-preserving for every downstream signature
— it only stops the workspace from callinghttptypes "axum types". The
axum::source baseline drops from 18 files / 32 occurrences to 9 files / 14
occurrences, all insider2e-http/src/(plan §5 step 3a). Steps 3b (R2E-owned
FromParts/IntoHttpResponsetraits) and 3c (aRouternewtype) are
deliberately not done — they are gated on the §5.3d decision about what
users are promised. -
The 11 example crates dropped their direct
tokio/tokio-util/
tokio-streamdependencies and go through the facade like the framework
does (rt::sync::*,rt::sleep/rt::timeout,rt::select!,
rt::TcpListener/rt::TcpStream,rt::io,rt::stream,#[r2e::test]).
With that the tokio dependency allowlist is exactly{r2e-rt, r2e-test, r2e-devservices}— the by-design set — and the tokio source baseline is
empty workspace-wide. -
r2e-observability:
traced_reqwest_client/TraceContextMiddleware
now open an OpenTelemetry client span per outgoing request
(otel.kind = "client", nameHTTP {method}, HTTP-client semantic
conventions:http.request.method,server.address,server.port,
url.full,http.response.status_code,otel.status_code/
error.message) and propagate that span's context instead of the
caller's. Tracing backends that derive a service graph from CLIENT→SERVER
pairs (Tempo metrics-generator, Jaeger, Grafana) now showcaller → callee
edges and client-side latency for R2E services calling each other.
Implemented onreqwest-tracingpinned to the workspace
opentelemetry 0.32/tracing-opentelemetry 0.33. New re-exports:
R2eSpanBackend,OtelName,OtelPathNames,DisableOtelPropagation.
inject_current_contextis unchanged (headers only, no client span).
Follow-up of the outgoing-propagation work (#764, #765, #766); task #927.