Skip to content

Commit 1d3b570

Browse files
committed
Fix warning
1 parent 4b84be9 commit 1d3b570

5 files changed

Lines changed: 353 additions & 3 deletions

File tree

crates/vespera_inprocess/benches/dispatch.rs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,103 @@ fn bench_async_spawn_pattern(c: &mut Criterion) {
789789
drop(runtime);
790790
}
791791

792+
/// Same-run A/B for the `RequestSourceCloser` hardening: the request-source
793+
/// close hook is now invoked under `catch_unwind` so a panicking hook running
794+
/// from `Drop` during unwind cannot double-panic -> `abort()` the host JVM.
795+
/// This isolates the added `catch_unwind` landing-pad cost vs a direct call,
796+
/// with BOTH arms in the SAME run so the measurement is immune to the
797+
/// cross-run thermal/load drift that swamps the dispatch-level `streaming_path`
798+
/// comparison (the close hook fires once per bidirectional dispatch, after the
799+
/// response body is fully drained, so its cost is amortised over an entire
800+
/// dispatch — this micro-A/B is the only instrument fine enough to resolve it).
801+
fn bench_close_hook_ab(c: &mut Criterion) {
802+
use std::panic::AssertUnwindSafe;
803+
let mut group = c.benchmark_group("close_hook_ab");
804+
805+
// `pre`: the previous direct `close()` call. `post`: the hardened
806+
// `catch_unwind(AssertUnwindSafe(close))`. The closure does a tiny
807+
// black-boxed op so it is neither optimised away nor large enough to
808+
// dwarf the landing-pad cost being measured.
809+
group.bench_function("direct_call_pre", |b| {
810+
b.iter(|| {
811+
let f = || std::hint::black_box(1u64).wrapping_mul(3);
812+
std::hint::black_box(f())
813+
});
814+
});
815+
816+
group.bench_function("catch_unwind_post", |b| {
817+
b.iter(|| {
818+
let f = || std::hint::black_box(1u64).wrapping_mul(3);
819+
std::hint::black_box(std::panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or(0))
820+
});
821+
});
822+
823+
group.finish();
824+
}
825+
826+
/// Same-run A/B for the Oracle-flagged `dispatchAsync` completion-isolation
827+
/// question: does completing the Java `CompletableFuture` from a
828+
/// `spawn_blocking` thread (so a blocking / re-entrant Java continuation runs
829+
/// OFF the core Tokio workers) cost enough to matter on the async path?
830+
///
831+
/// - `complete_inline_pre`: the future is completed inline on the dispatch
832+
/// worker (the pre-change behaviour) — no isolation hop.
833+
/// - `complete_spawn_blocking_post`: the completion is moved to a
834+
/// `spawn_blocking` thread — isolates Java continuations from the core
835+
/// workers at the cost of one blocking-pool hand-off.
836+
///
837+
/// Both arms run in the SAME run (drift-immune). The delta is the per-async-
838+
/// dispatch cost isolation would add, and decides whether to isolate
839+
/// unconditionally or document the `thenApplyAsync` contract instead (speed is
840+
/// the stated priority, so a large hop argues for the zero-cost doc contract).
841+
///
842+
/// VERDICT (measured, AMD Ryzen 9 9950X): `complete_inline_pre` ~1.5 µs vs
843+
/// `complete_spawn_blocking_post` ~24.5 µs — a ~16x per-dispatch regression.
844+
/// Forced isolation is therefore REJECTED (it violates the speed-first
845+
/// priority); the worker-thread completion is kept and the threading contract
846+
/// is documented on `dispatchAsync` instead (callers use `*Async` continuations
847+
/// and avoid blocking / re-entrant inline continuations). This A/B stays as
848+
/// the permanent regression-decision guard so the 16x cost is not re-discovered.
849+
fn bench_async_completion_isolation_ab(c: &mut Criterion) {
850+
let runtime = tokio::runtime::Builder::new_multi_thread()
851+
.worker_threads(4)
852+
.enable_all()
853+
.build()
854+
.expect("multi-thread runtime");
855+
let mut group = c.benchmark_group("async_completion_ab");
856+
857+
group.bench_function("complete_inline_pre", |b| {
858+
b.iter(|| {
859+
runtime.block_on(async {
860+
tokio::spawn(async move {
861+
let resp = std::hint::black_box(vec![0u8; 64]);
862+
std::hint::black_box(resp.len())
863+
})
864+
.await
865+
.unwrap()
866+
})
867+
});
868+
});
869+
870+
group.bench_function("complete_spawn_blocking_post", |b| {
871+
b.iter(|| {
872+
runtime.block_on(async {
873+
tokio::spawn(async move {
874+
let resp = std::hint::black_box(vec![0u8; 64]);
875+
tokio::task::spawn_blocking(move || std::hint::black_box(resp.len()))
876+
.await
877+
.unwrap()
878+
})
879+
.await
880+
.unwrap()
881+
})
882+
});
883+
});
884+
885+
group.finish();
886+
drop(runtime);
887+
}
888+
792889
/// Hand-rolled wire-header serde vs `serde_json` (within-run A/B).
793890
///
794891
/// Gates the Oracle-ranked #2 change: replacing `serde_json` on the
@@ -1059,7 +1156,9 @@ criterion_group!(
10591156
bench_registry_ab,
10601157
bench_headers_path,
10611158
bench_streaming_path,
1062-
bench_async_spawn_pattern
1159+
bench_async_spawn_pattern,
1160+
bench_close_hook_ab,
1161+
bench_async_completion_isolation_ab
10631162
);
10641163

10651164
// The within-run A/B groups compare the production hand-rolled paths against

crates/vespera_inprocess/src/streaming.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,11 +567,23 @@ impl<C: FnOnce()> RequestSourceCloser<C> {
567567
/// close hook is consumed on the first call, so later calls (including the
568568
/// one in `Drop`) are no-ops. If the producer never started the hook is
569569
/// dropped uncalled — there is nothing to close.
570+
///
571+
/// The hook runs under `catch_unwind`: `close_if_started` is also invoked
572+
/// from `Drop`, which can run while a panic is already unwinding out of the
573+
/// handler or the response-body poll, where a hook panic would be a
574+
/// double-panic → process `abort()` (taking the host JVM down with it). The
575+
/// close is best-effort cleanup (unblock a producer parked in a blocking
576+
/// read) that runs only AFTER the response is fully drained, so a panicking
577+
/// hook is contained rather than allowed to abort the process or fail an
578+
/// already-produced response.
570579
fn close_if_started(&mut self) {
571580
if let Some(close) = self.close.take()
572581
&& producer_was_started(&self.producer_handle)
573582
{
574-
close();
583+
// `AssertUnwindSafe`: the hook is `FnOnce()` best-effort cleanup and
584+
// the producer is being torn down regardless, so swallowing its
585+
// panic leaves no observable state inconsistent.
586+
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(close));
575587
}
576588
}
577589
}
@@ -748,3 +760,39 @@ async fn await_request_producer(producer_handle: &RequestProducerHandle) {
748760
let _ = handle.await;
749761
}
750762
}
763+
764+
#[cfg(test)]
765+
mod tests {
766+
use super::{RequestProducerHandle, RequestSourceCloser};
767+
use std::sync::{Arc, Mutex};
768+
769+
/// A panicking user close hook must be CONTAINED by `close_if_started`:
770+
/// the method also runs from `Drop` during unwind, where an escaping panic
771+
/// would be a double-panic → process `abort()`. Build a "started" producer
772+
/// handle (a real `JoinHandle`, so `producer_was_started` is true and the
773+
/// hook actually runs), then assert the call returns normally despite the
774+
/// hook panicking, and that a second call is a consumed-hook no-op.
775+
///
776+
/// Without the `catch_unwind` in `close_if_started`, the first call would
777+
/// unwind out of this `#[test]` (and, on a real `Drop`-during-unwind path,
778+
/// abort the process).
779+
#[test]
780+
fn close_hook_panic_is_contained() {
781+
let runtime = tokio::runtime::Builder::new_current_thread()
782+
.build()
783+
.expect("current-thread runtime");
784+
// `Runtime::spawn` hands back a live `JoinHandle` without entering the
785+
// runtime (the empty task is never driven or awaited) — we only need a
786+
// handle present so the producer counts as "started".
787+
let join_handle = runtime.spawn(async {});
788+
let producer_handle: RequestProducerHandle = Arc::new(Mutex::new(Some(join_handle)));
789+
790+
let mut closer =
791+
RequestSourceCloser::new(Arc::clone(&producer_handle), || panic!("hook boom"));
792+
// Returns normally — the panic is caught inside `close_if_started`.
793+
closer.close_if_started();
794+
// Idempotent: the hook was consumed on the first call, so this is a
795+
// no-op and does not panic a second time.
796+
closer.close_if_started();
797+
}
798+
}

crates/vespera_jni/src/jni_impl.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,26 @@ mod direct;
328328
/// The future is always completed with a valid wire response —
329329
/// it is never left dangling, even on internal errors.
330330
///
331+
/// # Threading contract (IMPORTANT)
332+
///
333+
/// The future is completed **on a Tokio runtime worker thread**, so any
334+
/// *non-async* `CompletableFuture` continuation (`thenApply`, `thenAccept`,
335+
/// `whenComplete`, …) runs **inline on that worker**. Callers MUST therefore:
336+
/// - attach heavy / blocking continuations with the `*Async` variants
337+
/// (`thenApplyAsync`, `whenCompleteAsync`, …) on their own executor, and
338+
/// - never re-enter a blocking vespera dispatch (`dispatchBytes` /
339+
/// `dispatchDirect`) from an inline continuation — that nests a `block_on`
340+
/// inside the runtime and degrades to a caught-panic `500`.
341+
///
342+
/// Completing the future off the worker (via `spawn_blocking`) was measured at
343+
/// ~16x the per-dispatch cost (`vespera_inprocess` `benches/dispatch.rs`,
344+
/// group `async_completion_ab`: ~1.5 µs inline vs ~24.5 µs hand-off), so the
345+
/// worker-thread completion is kept and this contract is documented instead —
346+
/// matching how Netty / async HTTP clients complete futures from their I/O
347+
/// threads. The autoconfigured Spring proxy never selects `ASYNC` (its
348+
/// `SmartDispatchModeResolver` uses DIRECT / SYNC / streaming), so this path is
349+
/// opt-in for callers doing their own `CompletableFuture` composition.
350+
///
331351
/// Cancellation: Java's `future.cancel(true)` does NOT abort the
332352
/// in-flight Rust task in this iteration (defer to follow-up).
333353
/// Java callers may still observe cancellation via `future.isCancelled()`.

crates/vespera_macro/src/schema_macro/defaults.rs

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,22 @@ pub(super) fn generate_sea_orm_default_attrs(
7575
let fn_name = format!("default_{struct_name}_{field_name}");
7676
let fn_ident = syn::Ident::new(&fn_name, proc_macro2::Span::call_site());
7777

78+
// Validate the literal against the field's type at macro-expansion
79+
// time: a malformed `default_value` (e.g. `"abc"` on an `i32`, or
80+
// `"300"` on a `u8`) becomes a COMPILE error pointing at the field
81+
// instead of the runtime panic the generated `#value.parse().unwrap()`
82+
// would raise the first time serde fills a missing field. A valid
83+
// literal keeps the byte-identical prior `.parse().unwrap()` body, so
84+
// no currently-valid default changes behaviour.
85+
let fn_body = match validate_literal_default(value, original_ty) {
86+
Ok(()) => quote! { #value.parse().unwrap() },
87+
Err(msg) => syn::Error::new_spanned(original_ty, msg).to_compile_error(),
88+
};
89+
7890
default_functions.push(quote! {
7991
#[allow(non_snake_case)]
8092
fn #fn_ident() -> #field_ty {
81-
#value.parse().unwrap()
93+
#fn_body
8294
}
8395
});
8496

@@ -180,6 +192,64 @@ pub(super) fn is_parseable_type(ty: &syn::Type) -> bool {
180192
type_utils::PRIMITIVE_TYPE_NAMES.contains(&segment.ident.to_string().as_str())
181193
}
182194

195+
/// Validate a literal `default_value` against the field's type **at
196+
/// macro-expansion time**, mirroring exactly the runtime `#value.parse()`
197+
/// the generated default function performs (no trimming — the generated
198+
/// code does not trim either, so this predicts the runtime result precisely).
199+
///
200+
/// Returns `Err(msg)` when the literal cannot parse to the concrete field
201+
/// type, so the caller emits a `compile_error!` (pointing at the field)
202+
/// instead of generating a `.parse().unwrap()` that panics the first time
203+
/// serde fills a missing field. Types whose `FromStr` cannot be faithfully
204+
/// reproduced here return `Ok(())`:
205+
/// - `String` — its `FromStr` is infallible.
206+
/// - `Decimal` — needs the `rust_decimal` runtime crate; left to runtime.
207+
/// - any non-primitive / unknown type — already gated out by
208+
/// [`is_parseable_type`] before this is reached.
209+
fn validate_literal_default(value: &str, ty: &syn::Type) -> Result<(), String> {
210+
let syn::Type::Path(type_path) = ty else {
211+
return Ok(());
212+
};
213+
let Some(segment) = type_path.path.segments.last() else {
214+
return Ok(());
215+
};
216+
let type_name = segment.ident.to_string();
217+
218+
// Parse against the EXACT field type so a range violation (e.g. `"300"`
219+
// on a `u8`) is caught, not just a syntactic one. The message carries
220+
// the offending value and type plus the underlying `FromStr` error — the
221+
// same error the runtime `.unwrap()` would have panicked with.
222+
macro_rules! check {
223+
($t:ty) => {
224+
value
225+
.parse::<$t>()
226+
.map(|_| ())
227+
.map_err(|e| format!("invalid default_value {value:?} for `{type_name}`: {e}"))
228+
};
229+
}
230+
231+
match type_name.as_str() {
232+
"i8" => check!(i8),
233+
"i16" => check!(i16),
234+
"i32" => check!(i32),
235+
"i64" => check!(i64),
236+
"i128" => check!(i128),
237+
"isize" => check!(isize),
238+
"u8" => check!(u8),
239+
"u16" => check!(u16),
240+
"u32" => check!(u32),
241+
"u64" => check!(u64),
242+
"u128" => check!(u128),
243+
"usize" => check!(usize),
244+
"f32" => check!(f32),
245+
"f64" => check!(f64),
246+
"bool" => check!(bool),
247+
// `String::FromStr` is infallible; `Decimal` needs the runtime crate.
248+
// Everything else is gated out by `is_parseable_type` before this call.
249+
_ => Ok(()),
250+
}
251+
}
252+
183253
#[cfg(test)]
184254
mod tests {
185255
use std::collections::HashMap;
@@ -196,10 +266,102 @@ mod tests {
196266
items.into_iter().map(|s| (s.name.clone(), s)).collect()
197267
}
198268

269+
// ======================================
270+
// validate_literal_default tests
271+
// ======================================
272+
273+
#[test]
274+
fn validate_literal_default_accepts_valid_primitives() {
275+
let i32_ty: syn::Type = syn::parse_str("i32").unwrap();
276+
assert!(validate_literal_default("42", &i32_ty).is_ok());
277+
let u8_ty: syn::Type = syn::parse_str("u8").unwrap();
278+
assert!(validate_literal_default("255", &u8_ty).is_ok());
279+
let f64_ty: syn::Type = syn::parse_str("f64").unwrap();
280+
assert!(validate_literal_default("0.7", &f64_ty).is_ok());
281+
let bool_ty: syn::Type = syn::parse_str("bool").unwrap();
282+
assert!(validate_literal_default("true", &bool_ty).is_ok());
283+
// String FromStr is infallible; Decimal is intentionally left to runtime.
284+
let string_ty: syn::Type = syn::parse_str("String").unwrap();
285+
assert!(validate_literal_default("anything at all", &string_ty).is_ok());
286+
let decimal_ty: syn::Type = syn::parse_str("Decimal").unwrap();
287+
assert!(validate_literal_default("not-validated-here", &decimal_ty).is_ok());
288+
}
289+
290+
#[test]
291+
fn validate_literal_default_rejects_unparseable_and_out_of_range() {
292+
let i32_ty: syn::Type = syn::parse_str("i32").unwrap();
293+
assert!(validate_literal_default("abc", &i32_ty).is_err());
294+
// Range violation caught against the EXACT type, not a generic integer.
295+
let u8_ty: syn::Type = syn::parse_str("u8").unwrap();
296+
assert!(validate_literal_default("300", &u8_ty).is_err());
297+
let bool_ty: syn::Type = syn::parse_str("bool").unwrap();
298+
assert!(validate_literal_default("maybe", &bool_ty).is_err());
299+
let f64_ty: syn::Type = syn::parse_str("f64").unwrap();
300+
assert!(validate_literal_default("3.14.15", &f64_ty).is_err());
301+
}
302+
199303
// ======================================
200304
// generate_sea_orm_default_attrs tests
201305
// ======================================
202306

307+
#[test]
308+
fn test_sea_orm_default_attrs_valid_literal_keeps_parse_unwrap() {
309+
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])];
310+
let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site());
311+
let ty: syn::Type = syn::parse_str("i32").unwrap();
312+
let mut fns = Vec::new();
313+
let (serde, _schema) = generate_sea_orm_default_attrs(
314+
&attrs,
315+
&struct_name,
316+
"count",
317+
&ty,
318+
&ty,
319+
false,
320+
&mut fns,
321+
);
322+
assert!(serde.to_string().contains("serde"));
323+
assert_eq!(fns.len(), 1);
324+
let body = fns[0].to_string();
325+
assert!(body.contains("parse"), "valid literal keeps parse: {body}");
326+
assert!(body.contains("unwrap"), "valid literal keeps unwrap: {body}");
327+
assert!(
328+
!body.contains("compile_error"),
329+
"valid literal must not emit compile_error: {body}"
330+
);
331+
}
332+
333+
#[test]
334+
fn test_sea_orm_default_attrs_invalid_literal_emits_compile_error() {
335+
// `"abc"` cannot parse to i32: the generated default function body must
336+
// be a compile_error (pointing at the field) instead of a runtime
337+
// `.parse().unwrap()` that would panic when serde fills a missing field.
338+
let attrs: Vec<syn::Attribute> =
339+
vec![syn::parse_quote!(#[sea_orm(default_value = "abc")])];
340+
let struct_name = syn::Ident::new("Test", proc_macro2::Span::call_site());
341+
let ty: syn::Type = syn::parse_str("i32").unwrap();
342+
let mut fns = Vec::new();
343+
let (serde, _schema) = generate_sea_orm_default_attrs(
344+
&attrs,
345+
&struct_name,
346+
"count",
347+
&ty,
348+
&ty,
349+
false,
350+
&mut fns,
351+
);
352+
assert!(serde.to_string().contains("serde"));
353+
assert_eq!(fns.len(), 1);
354+
let body = fns[0].to_string();
355+
assert!(
356+
body.contains("compile_error"),
357+
"invalid literal must emit compile_error: {body}"
358+
);
359+
assert!(
360+
!body.contains("unwrap"),
361+
"invalid literal must not emit a runtime parse().unwrap(): {body}"
362+
);
363+
}
364+
203365
#[test]
204366
fn test_sea_orm_default_attrs_optional_field_skips() {
205367
let attrs: Vec<syn::Attribute> = vec![syn::parse_quote!(#[sea_orm(default_value = "42")])];

0 commit comments

Comments
 (0)