View all comments
Observed on aarch64-apple-darwin.
It also consistently hits my CI runners since release of 1.98,
failing our CI... each commit (https://github.com/plabayo/rama)
The same Rama source works with Rust 1.97.1, crashes with
Rust 1.98.0, and works again with nightly-2026-07-16.
Rust 1.98 emits a zero method entry in a compiler-generated
vtable. Safe Rust dispatches through that entry and the process
segfaults at address zero.
Reproduction
Based on my CI failures I made a small example using our rama Repo,
note that it assumes TCP port 62017 is available:
git clone https://github.com/plabayo/rama.git
cd rama
git checkout --detach 0b4aa58e33e9d07db2718a3886c05b673ba9a70e
rustup toolchain install 1.98.0
just clean
CARGO_TARGET_DIR=target-ticket-198 \
cargo +1.98.0 build \
-p rama-examples \
--features=cli,tcp,http-full,proxy-full,boring \
--bin http_mitm_proxy_boring
Than run:
./target-ticket-198/debug/http_mitm_proxy_boring \
>/tmp/rama-server-198.log 2>&1 &
proxy_pid=$!
sleep 2
curl -skv --max-time 5 \
--proxy http://john:secret@127.0.0.1:62017 \
https://127.0.0.1:1/ \
>/tmp/rama-curl-198.log 2>&1 || true
if kill -0 "$proxy_pid" 2>/dev/null; then
echo "proxy is still alive"
kill "$proxy_pid"
wait "$proxy_pid" 2>/dev/null || true
else
wait "$proxy_pid"
echo "proxy exited with status $?"
fi
sed -n '1,120p' /tmp/rama-curl-198.log
sed -n '1,120p' /tmp/rama-server-198.log
Repeat with separate targets (w/ clean target directory) for 1.97.1 and nightly-2026-07-16 (tried to narrow down...).
What you should see is the following:
1.97.1: 502 Bad Gateway; proxy remains alive
1.98.0: CONNECT aborts; SIGSEGV at address zero
nightly-2026-07-16: 502 Bad Gateway; proxy remains alive
In short, it is expected that the failed upstream connection should be converted into HTTP/1.1 502 Bad Gateway, and the proxy should remain alive.
Instead it is obsered in 1.98 that the CONNECT request is aborted. macOS reports SIGSEGV with pc=0. The generated caller loads the Service method from vtable + 0x18 and branches to the zero value stored there.
Diving into the code it seems that the Rust 1.98 vtable contains valid drop/size/alignment fields,
but the first method slot at offset 24 is zero. -Zprint-mono-items collects the erased caller on Rust 1.98, but
not EagerHttpProxyConnector's concrete Service::serve method. nightly-2026-07-16 collects both the method and its async closures.
The method may be reaching VtblEntry::Vacant because its predicates are incorrectly considered impossible.
Relevant rama types:
Meta
macOS crash report:
Exception: EXC_BAD_ACCESS (SIGSEGV)
Subtype: KERN_INVALID_ADDRESS at 0x0000000000000000
Termination: Segmentation fault: 11
Faulting thread: tokio-rt-worker
PC: 0x0000000000000000
LR: 0x0000000102511674
The link register resolves to:
rama_http::layer::upgrade::service::
<UpgradeService<...> as Service<Request>>::serve::{closure#0}
+ 2956
Backtrace
Thread 2 Crashed: tokio-rt-worker
0 0x0000000000000000
1 <UpgradeService<...> as Service<Request>>::serve::{closure#0}
2 <tracing::instrument::Instrumented<
Trace<...>::serve::{closure#0}
> as Future>::poll
3 <rama_http_core::proto::h1::dispatch::Server<...>
as Dispatch>::recv_msg::{closure#0}
4 rama_http_core::proto::h1::dispatch::Dispatcher<...>::poll_write
5 rama_http_core::proto::h1::dispatch::Dispatcher<...>::poll_catch
6 <rama_http_core::server::conn::http1::
UpgradeableConnection<...> as Future>::poll
7 <rama_http_core::server::conn::auto::
UpgradeableConnection<...> as Future>::poll
...
13 tokio::runtime::scheduler::multi_thread::worker::Context::run_task
14 tokio::runtime::scheduler::multi_thread::worker::Context::run
The program counter is exactly zero. The link register points inside UpgradeService::serve, at the indirect boxed Service call. Together with the emitted vtable containing zero at method offset 0x18, this indicates that dispatch branches through the null vtable entry.
This may be a recurrence of #152735/#153596 and appears closely related to #158148. It behaviour seems slightly different though: Rust 1.98 silently emits a vacant method slot and the resulting binary crashes, rather than rustc producing an ICE.
Note that this is for some reason the only location where it triggers despite:
- boxed services being used in many other places
- that we use that async dynamic trait dispatch trick for several other of our traits as well
Yet somehow only with this trait in this exact example it is failing... So there is hope I am just doing something weird here, as I havent found a stable rustc bug in 11 years that I used Rust in production.... So I'm really hoping to be wrong here...
Workaround
Found a work around but I would rather not have to add these to the codebase...
Because either I am (a) doing something bad since long and I rather just fix it properly in that case,
as it would mean I was just "lucky" so far. Or this is a real rust bug and in that case I rather help get a 1.98.1 through the door... So far my money is still on me doing something wrong ... somehow... but I don't see how.
AFAIK that code hasn't changed in any way, and it clearly reproduces on rust 1.98 while it does not on previous versions (e.g. 1.97.1) or nightly versions slightly there-after...
Work around
diff --git a/rama-http/src/layer/upgrade/service.rs b/rama-http/src/layer/upgrade/service.rs
index d21adcc8b..eaf030fae 100644
--- a/rama-http/src/layer/upgrade/service.rs
+++ b/rama-http/src/layer/upgrade/service.rs
@@ -125,7 +125,7 @@ pub struct UpgradeHandler<O> {
enum UpgradeHandlerKind<O> {
ResponseLocal {
- responder: BoxService<Request, UpgradeOutput<Request, O>, O>,
+ responder: ResponseLocalResponder<O>,
handler_error_sink: Arc<dyn ErrorSink>,
},
SeparateServices {
@@ -149,6 +149,13 @@ struct PreparedUpgrade<O> {
continuation: UpgradeContinuation,
}
+type ResponseLocalResponderFuture<O> =
+ Pin<Box<dyn Future<Output = Result<UpgradeOutput<Request, O>, O>> + Send + 'static>>;
+// Keep the generic `R: Service` predicate outside the erased call boundary.
+// Rust 1.98 can otherwise emit a vacant `BoxService` vtable entry here.
+type ResponseLocalResponder<O> =
+ Arc<dyn Fn(Request) -> ResponseLocalResponderFuture<O> + Send + Sync + 'static>;
+
impl<O: Send + 'static> UpgradeHandler<O> {
/// Register one service which returns its response-local upgrade handler.
pub(crate) fn new<M, R, Sink>(matcher: M, responder: R, sink: Sink) -> Self
@@ -157,10 +164,15 @@ impl<O: Send + 'static> UpgradeHandler<O> {
R: Service<Request, Output = UpgradeOutput<Request, O>, Error = O> + Clone,
Sink: ErrorSink,
{
+ let responder: ResponseLocalResponder<O> = Arc::new(move |request| {
+ let responder = responder.clone();
+ Box::pin(async move { responder.serve(request).await })
+ });
+
Self {
matcher: Box::new(matcher),
kind: UpgradeHandlerKind::ResponseLocal {
- responder: responder.boxed(),
+ responder,
handler_error_sink: Arc::new(sink),
},
_phantom: std::marker::PhantomData,
@@ -203,7 +215,7 @@ impl<O: Send + 'static> UpgradeHandler<O> {
UpgradeHandlerKind::ResponseLocal {
responder,
handler_error_sink,
- } => responder.serve(request).await.map(
+ } => responder(request).await.map(
|UpgradeOutput {
response,
request,
Prior to that I also had a work around which changed all boxed services ,not just this specific use case of it,
I also recall that working. Giving it here as well as extra info:
box service global work around
diff --git a/rama-core/src/service/svc.rs b/rama-core/src/service/svc.rs
--- a/rama-core/src/service/svc.rs
+++ b/rama-core/src/service/svc.rs
@@ -1,5 +1,6 @@
//! [`Service`] and [`BoxService`] traits.
+use core::any::Any;
use core::convert::Infallible;
use core::fmt;
use core::marker::PhantomData;
@@ -145,46 +146,41 @@ where
}
}
-/// Internal trait for dynamic dispatch of Async Traits,
-/// implemented according to the pioneers of this Design Pattern
-/// found at <https://rust-lang.github.io/async-fundamentals-initiative/evaluation/case-studies/builder-provider-api.html#dynamic-dispatch-behind-the-api>
-/// and widely published at <https://blog.rust-lang.org/inside-rust/2023/05/03/stabilizing-async-fn-in-trait.html>.
-trait DynService<Input> {
- type Output;
- type Error;
-
- #[expect(clippy::type_complexity)]
- fn serve_box(
- &self,
- input: Input,
- ) -> Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + Send + '_>>;
-}
+// Keep dispatch separate from the erased value's vtable. Rust 1.98 can emit a
+// vacant method slot for the custom async-erasure trait with complex services.
+type ErasedService = dyn Any + Send + Sync + 'static;
+type BoxServiceFuture<'a, Output, Error> =
+ Pin<Box<dyn Future<Output = Result<Output, Error>> + Send + 'a>>;
+type ServeErasedFn<Input, Output, Error> =
+ for<'a> fn(&'a ErasedService, Input) -> BoxServiceFuture<'a, Output, Error>;
-impl<Input, T> DynService<Input> for T
+fn serve_erased<'a, Input, Output, Error, T>(
+ inner: &'a ErasedService,
+ input: Input,
+) -> BoxServiceFuture<'a, Output, Error>
where
- T: Service<Input>,
+ T: Service<Input, Output = Output, Error = Error>,
{
- type Output = T::Output;
- type Error = T::Error;
-
- fn serve_box(
- &self,
- input: Input,
- ) -> Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + Send + '_>> {
- Box::pin(self.serve(input))
- }
+ #[expect(
+ clippy::unwrap_used,
+ reason = "the constructor stores the same service type as its dispatch function"
+ )]
+ let service = inner.downcast_ref::<T>().unwrap();
+ Box::pin(service.serve(input))
}
/// A boxed [`Service`], to serve Inputs with,
/// for where you inputuire dynamic dispatch.
pub struct BoxService<Input, Output, Error> {
- inner: Arc<dyn DynService<Input, Output = Output, Error = Error> + Send + Sync + 'static>,
+ inner: Arc<ErasedService>,
+ serve: ServeErasedFn<Input, Output, Error>,
}
impl<Input, Output, Error> Clone for BoxService<Input, Output, Error> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
+ serve: self.serve,
}
}
}
@@ -198,6 +194,7 @@ impl<Input, Output, Error> BoxService<Input, Output, Error> {
{
Self {
inner: Arc::new(service),
+ serve: serve_erased::<Input, Output, Error, T>,
}
}
}
@@ -223,7 +220,7 @@ where
input: Input,
) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send + '_ {
- self.inner.serve_box(input)
+ (self.serve)(self.inner.as_ref(), input)
}
#[inline]
View all comments
Observed on aarch64-apple-darwin.
It also consistently hits my CI runners since release of 1.98,
failing our CI... each commit (https://github.com/plabayo/rama)
The same Rama source works with Rust 1.97.1, crashes with
Rust 1.98.0, and works again with nightly-2026-07-16.
Rust 1.98 emits a zero method entry in a compiler-generated
vtable. Safe Rust dispatches through that entry and the process
segfaults at address zero.
Reproduction
Based on my CI failures I made a small example using our rama Repo,
note that it assumes TCP port 62017 is available:
git clone https://github.com/plabayo/rama.git cd rama git checkout --detach 0b4aa58e33e9d07db2718a3886c05b673ba9a70e rustup toolchain install 1.98.0 just clean CARGO_TARGET_DIR=target-ticket-198 \ cargo +1.98.0 build \ -p rama-examples \ --features=cli,tcp,http-full,proxy-full,boring \ --bin http_mitm_proxy_boringThan run:
Repeat with separate targets (w/ clean target directory) for
1.97.1andnightly-2026-07-16(tried to narrow down...).What you should see is the following:
1.97.1: 502 Bad Gateway; proxy remains alive1.98.0: CONNECT aborts; SIGSEGV at address zeronightly-2026-07-16: 502 Bad Gateway; proxy remains aliveIn short, it is expected that the failed upstream connection should be converted into HTTP/1.1 502 Bad Gateway, and the proxy should remain alive.
Instead it is obsered in 1.98 that the CONNECT request is aborted. macOS reports SIGSEGV with pc=0. The generated caller loads the Service method from vtable + 0x18 and branches to the zero value stored there.
Diving into the code it seems that the Rust 1.98 vtable contains valid drop/size/alignment fields,
but the first method slot at offset 24 is zero.
-Zprint-mono-itemscollects the erased caller on Rust 1.98, butnot
EagerHttpProxyConnector's concreteService::servemethod.nightly-2026-07-16collects both the method and its async closures.The method may be reaching
VtblEntry::Vacantbecause its predicates are incorrectly considered impossible.Relevant rama types:
Meta
Backtrace
The program counter is exactly zero. The link register points inside UpgradeService::serve, at the indirect boxed
Servicecall. Together with the emitted vtable containing zero at method offset0x18,this indicates that dispatch branches through the null vtable entry.This may be a recurrence of #152735/#153596 and appears closely related to #158148. It behaviour seems slightly different though: Rust 1.98 silently emits a vacant method slot and the resulting binary crashes, rather than rustc producing an ICE.
Note that this is for some reason the only location where it triggers despite:
Yet somehow only with this trait in this exact example it is failing... So there is hope I am just doing something weird here, as I havent found a stable
rustcbug in 11 years that I used Rust in production.... So I'm really hoping to be wrong here...Workaround
Found a work around but I would rather not have to add these to the codebase...
Because either I am (a) doing something bad since long and I rather just fix it properly in that case,
as it would mean I was just "lucky" so far. Or this is a real rust bug and in that case I rather help get a 1.98.1 through the door... So far my money is still on me doing something wrong ... somehow... but I don't see how.
AFAIK that code hasn't changed in any way, and it clearly reproduces on rust 1.98 while it does not on previous versions (e.g. 1.97.1) or nightly versions slightly there-after...
Work around
Prior to that I also had a work around which changed all boxed services ,not just this specific use case of it,
I also recall that working. Giving it here as well as extra info:
box service global work around