Skip to content

Commit 26bb863

Browse files
committed
Fix jni
1 parent 9a6d7f8 commit 26bb863

20 files changed

Lines changed: 1041 additions & 519 deletions

crates/vespera/src/multipart.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,28 @@ pub fn register_multipart_part() -> Result<(), TypedMultipartError> {
607607
.unwrap_or(Ok(()))
608608
}
609609

610-
fn register_multipart_bytes(field_name: &str, chunk_len: usize) -> Result<(), TypedMultipartError> {
610+
/// Count `chunk_len` bytes of one multipart field against the request-wide
611+
/// `max_total_bytes` aggregate limit, returning [`TypedMultipartError::RequestTooLarge`]
612+
/// once the running total crosses the cap.
613+
///
614+
/// The public counterpart of [`register_multipart_part`] for the **byte**
615+
/// dimension of [`MultipartLimits`]. Vespera's built-in field parsers
616+
/// ([`read_field_data`] / the `NamedTempFile` path) already call this once per
617+
/// `field.chunk()`, so typed multipart structs are accounted automatically.
618+
///
619+
/// A **custom [`TryFromFieldWithState`] implementation that consumes a field's
620+
/// bytes itself** (via `field.chunk()` / `field.bytes()`) MUST call this once
621+
/// per chunk to participate in the aggregate cap — otherwise that field's bytes
622+
/// are invisible to `max_total_bytes` and a single custom-parsed field can read
623+
/// unboundedly past the configured policy. The per-field `limit_bytes` passed to
624+
/// the trait method still bounds that one field, but only this call enforces the
625+
/// request-wide total. Mirrors the cooperative contract of
626+
/// [`register_multipart_part`]: outside the extractor's task-local scope (e.g. a
627+
/// direct unit test of a derived parser) it no-ops rather than failing.
628+
pub fn register_multipart_bytes(
629+
field_name: &str,
630+
chunk_len: usize,
631+
) -> Result<(), TypedMultipartError> {
611632
MULTIPART_AGGREGATE
612633
.try_with(|state| {
613634
let mut state = state.borrow_mut();

crates/vespera/src/multipart/tests.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,45 @@ fn temp_file_default_limit_is_bounded_and_configurable() {
7676
);
7777
}
7878

79+
#[test]
80+
fn register_multipart_bytes_lets_custom_parsers_enforce_aggregate_cap() {
81+
// A custom `TryFromFieldWithState` impl that consumes a field's bytes itself
82+
// can now call the public `register_multipart_bytes` to participate in the
83+
// request-wide `max_total_bytes` cap — previously impossible (the counter was
84+
// private), so a single custom-parsed field could read unboundedly past the
85+
// configured `MultipartLimits`.
86+
let rt = tokio::runtime::Builder::new_current_thread()
87+
.build()
88+
.expect("current-thread runtime");
89+
let outcome = rt.block_on(async {
90+
let limits = MultipartLimits::new(10, DEFAULT_MULTIPART_MAX_FIELDS);
91+
MULTIPART_AGGREGATE
92+
.scope(RefCell::new(MultipartAggregateState::new(limits)), async {
93+
// Under the cap: two 4-byte chunks accepted (8 <= 10).
94+
register_multipart_bytes("custom", 4)?;
95+
register_multipart_bytes("custom", 4)?;
96+
// Crossing the cap (8 + 4 = 12 > 10) trips RequestTooLarge.
97+
register_multipart_bytes("custom", 4)
98+
})
99+
.await
100+
});
101+
assert!(
102+
matches!(
103+
outcome,
104+
Err(TypedMultipartError::RequestTooLarge {
105+
limit_bytes: 10,
106+
..
107+
})
108+
),
109+
"custom-parser byte accounting must trip the aggregate cap, got {outcome:?}"
110+
);
111+
112+
// Cooperative contract (mirrors `register_multipart_part`): outside the
113+
// extractor's task-local scope it no-ops rather than erroring, so a derived
114+
// parser can be unit-tested without a live request aggregate.
115+
assert!(register_multipart_bytes("custom", usize::MAX).is_ok());
116+
}
117+
79118
// ─── Display tests for all error variants ───────────────────────────
80119

81120
#[test]

crates/vespera_core/src/schema.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,17 @@ impl Serialize for Schema {
560560
"invalid Schema: nullable `$ref` serializes through anyOf and cannot also carry explicit any_of",
561561
));
562562
}
563+
// A nullable `$ref` is emitted as `anyOf: [{$ref}, {type:null}]`; a
564+
// sibling `type` would then describe the SAME node twice and produce
565+
// ambiguous/invalid output (`anyOf` AND `type` at one level). Vespera's
566+
// own `Schema::nullable_reference` always leaves `schema_type` None, so
567+
// this only fires for a hand-built `Schema` that mixed the two — reject
568+
// it like the `any_of` case above instead of serializing broken OpenAPI.
569+
if nullable_ref && self.schema_type.is_some() {
570+
return Err(serde::ser::Error::custom(
571+
"invalid Schema: nullable `$ref` serializes through anyOf and cannot also carry an explicit type; build it via Schema::nullable_reference",
572+
));
573+
}
563574
let mut out = serializer.serialize_struct("Schema", 42)?;
564575
if let Some(ref_path) = &self.ref_path {
565576
if nullable_ref {

crates/vespera_core/src/schema/tests.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,27 @@ fn nullable_reference_with_explicit_any_of_returns_clean_serialization_error() {
328328
);
329329
}
330330

331+
#[test]
332+
fn nullable_reference_with_explicit_type_returns_clean_serialization_error() {
333+
// A hand-built nullable `$ref` that ALSO carries a `schema_type` would
334+
// serialize both `anyOf` and a sibling `type` — ambiguous/invalid OpenAPI.
335+
// It must fail with a clean serialization error like the `any_of` case,
336+
// not silently emit the broken shape. (Vespera's own `nullable_reference`
337+
// leaves `schema_type` None, so this only guards external manual construction.)
338+
let schema = Schema {
339+
schema_type: Some(SchemaType::Object),
340+
..Schema::nullable_reference("#/components/schemas/User".to_owned())
341+
};
342+
343+
let err = serde_json::to_string(&schema).unwrap_err();
344+
345+
assert!(
346+
err.to_string()
347+
.contains("cannot also carry an explicit type"),
348+
"unexpected error: {err}",
349+
);
350+
}
351+
331352
#[test]
332353
fn nullable_primitive_emits_type_array_with_null() {
333354
let schema = Schema {

crates/vespera_jni/src/jni_impl.rs

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,8 @@ fn panic_wire() -> Vec<u8> {
198198
#[path = "jni_impl_support.rs"]
199199
mod support;
200200
use support::{
201-
push_unless_header_failed, setup_full_stream, setup_full_stream_with_header, setup_stream,
202-
setup_stream_with_header, should_fire_fallback_header, throw_streaming_abort,
201+
PanicHeaderAction, panic_post_header_action, push_unless_header_failed, setup_full_stream,
202+
setup_full_stream_with_header, setup_stream, setup_stream_with_header, throw_streaming_abort,
203203
};
204204

205205
/// `com.devfive.vespera.bridge.VesperaBridge.dispatchBytes(byte[]) -> byte[]`
@@ -641,8 +641,11 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStr
641641
// the consumer once with a 500 header below so the documented
642642
// "header consumer invoked exactly once on every code path"
643643
// contract holds and the Java caller is not left hanging. A
644-
// panic AFTER the header fired leaves Spring's response partially
645-
// committed — unrecoverable, but the contract is already met.
644+
// panic AFTER the header fired truncates the body past a header the
645+
// host already committed; `panic_post_header_action` then throws
646+
// IOException to abort the response (symmetric with the body-error /
647+
// sink-stop abort on the `Ok` branch) instead of finishing cleanly
648+
// over a short body.
646649
let header_sent = Arc::new(AtomicBool::new(false));
647650
let header_failed = Arc::new(AtomicBool::new(false));
648651
let header_sent_cb = Arc::clone(&header_sent);
@@ -694,24 +697,29 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchStr
694697
}
695698
}
696699
Err(_) => {
697-
// See `should_fire_fallback_header`: a panic re-enters the
698-
// header consumer ONLY when it was never invoked (neither
699-
// succeeded nor threw), upholding the "invoked exactly once on
700-
// every code path" contract.
701-
if should_fire_fallback_header(
700+
// A panic unwound out of the dispatch future. The action
701+
// depends on whether the response header was already committed
702+
// (see `panic_post_header_action`).
703+
match panic_post_header_action(
702704
header_sent.load(Ordering::Relaxed),
703705
header_failed.load(Ordering::Acquire),
704706
) {
705-
let err = panic_wire();
706-
// On the JNI entry thread `header_consumer` is still a
707-
// valid LOCAL ref, so deliver the mandatory fallback
708-
// header through it directly. Promoting it to a
709-
// `Global` here added an avoidable allocation AND a
710-
// failure point: a failed `new_global_ref` (e.g. OOM)
711-
// silently skipped the required single callback and
712-
// hung the Java caller. `call_header_consumer_local`
713-
// exists for exactly this cold on-thread fallback.
714-
let _ = call_header_consumer_local(env, &header_consumer, &err);
707+
// Header never reached the consumer: deliver the one-shot
708+
// 500 fallback through the still-valid LOCAL `header_consumer`
709+
// ref (no `Global` promotion to fail first and hang the
710+
// caller), upholding "invoked exactly once on every code path".
711+
PanicHeaderAction::FireFallbackHeader => {
712+
let err = panic_wire();
713+
let _ = call_header_consumer_local(env, &header_consumer, &err);
714+
}
715+
// Header already committed (or its delivery threw): the body
716+
// is now truncated past a header the host already wrote, so
717+
// throw IOException to abort the response instead of finishing
718+
// cleanly over a short body — symmetric with the body-error /
719+
// sink-stop abort on the `Ok` branch above.
720+
PanicHeaderAction::ThrowAbort => {
721+
throw_streaming_abort(env, header_failed.load(Ordering::Acquire));
722+
}
715723
}
716724
}
717725
}
@@ -851,24 +859,29 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchFul
851859
}
852860
}
853861
Err(_) => {
854-
// See `should_fire_fallback_header`: a panic re-enters the
855-
// header consumer ONLY when it was never invoked (neither
856-
// succeeded nor threw), upholding the "invoked exactly once on
857-
// every code path" contract.
858-
if should_fire_fallback_header(
862+
// A panic unwound out of the dispatch future. The action
863+
// depends on whether the response header was already committed
864+
// (see `panic_post_header_action`).
865+
match panic_post_header_action(
859866
header_sent.load(Ordering::Relaxed),
860867
header_failed.load(Ordering::Acquire),
861868
) {
862-
let err = panic_wire();
863-
// On the JNI entry thread `header_consumer` is still a
864-
// valid LOCAL ref, so deliver the mandatory fallback
865-
// header through it directly. Promoting it to a
866-
// `Global` here added an avoidable allocation AND a
867-
// failure point: a failed `new_global_ref` (e.g. OOM)
868-
// silently skipped the required single callback and
869-
// hung the Java caller. `call_header_consumer_local`
870-
// exists for exactly this cold on-thread fallback.
871-
let _ = call_header_consumer_local(env, &header_consumer, &err);
869+
// Header never reached the consumer: deliver the one-shot
870+
// 500 fallback through the still-valid LOCAL `header_consumer`
871+
// ref (no `Global` promotion to fail first and hang the
872+
// caller), upholding "invoked exactly once on every code path".
873+
PanicHeaderAction::FireFallbackHeader => {
874+
let err = panic_wire();
875+
let _ = call_header_consumer_local(env, &header_consumer, &err);
876+
}
877+
// Header already committed (or its delivery threw): the body
878+
// is now truncated past a header the host already wrote, so
879+
// throw IOException to abort the response instead of finishing
880+
// cleanly over a short body — symmetric with the body-error /
881+
// sink-stop abort on the `Ok` branch above.
882+
PanicHeaderAction::ThrowAbort => {
883+
throw_streaming_abort(env, header_failed.load(Ordering::Acquire));
884+
}
872885
}
873886
}
874887
}

crates/vespera_jni/src/jni_impl_streaming_abort_tests.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
use std::ops::ControlFlow;
22
use std::sync::atomic::{AtomicBool, Ordering};
33

4-
use super::{push_unless_header_failed, should_fire_fallback_header};
4+
use super::support::{
5+
PanicHeaderAction, panic_post_header_action, push_unless_header_failed,
6+
should_fire_fallback_header,
7+
};
58

69
#[test]
710
fn push_gate_aborts_without_writing_when_header_delivery_failed() {
@@ -70,3 +73,36 @@ fn fallback_header_fires_only_when_consumer_never_invoked() {
7073
// re-fire.
7174
assert!(!should_fire_fallback_header(true, true));
7275
}
76+
77+
#[test]
78+
fn panic_post_header_action_aborts_once_header_is_committed() {
79+
// Panic BEFORE the header was ever delivered: the Java caller has no header,
80+
// so the one-shot 500 fallback must be delivered (never an abort, which
81+
// would leave the caller with neither a header nor a result).
82+
assert_eq!(
83+
panic_post_header_action(false, false),
84+
PanicHeaderAction::FireFallbackHeader
85+
);
86+
87+
// Header already SUCCEEDED, then the dispatch future panicked mid-body: the
88+
// body is truncated past a committed header, so the transport must be
89+
// aborted — re-firing the consumer is forbidden (already invoked once).
90+
assert_eq!(
91+
panic_post_header_action(true, false),
92+
PanicHeaderAction::ThrowAbort
93+
);
94+
95+
// Header delivery THREW (consumer already invoked, response already broken):
96+
// a later panic must abort rather than re-enter the consumer.
97+
assert_eq!(
98+
panic_post_header_action(false, true),
99+
PanicHeaderAction::ThrowAbort
100+
);
101+
102+
// Defensive: both flags set never co-occurs, but must still abort, never
103+
// double-invoke the consumer.
104+
assert_eq!(
105+
panic_post_header_action(true, true),
106+
PanicHeaderAction::ThrowAbort
107+
);
108+
}

crates/vespera_jni/src/jni_impl_support.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,43 @@ pub(super) fn should_fire_fallback_header(header_sent: bool, header_failed: bool
5858
!header_sent && !header_failed
5959
}
6060

61+
/// What the panic landing-pad of a streaming-with-header dispatch must do after
62+
/// a Rust panic unwound out of the dispatch future, given whether the response
63+
/// header was already delivered.
64+
///
65+
/// Mirror image of the SUCCESS branch's truncation handling: that branch throws
66+
/// [`throw_streaming_abort`] when the body errors or the sink stops *after* the
67+
/// header was committed (`failed_header || BodyError | SinkStopped`). A panic
68+
/// after a committed header is the SAME failure shape — the body is truncated
69+
/// past a header the host already wrote — so it must abort the transport too,
70+
/// not return cleanly over a short body.
71+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72+
pub(super) enum PanicHeaderAction {
73+
/// The header consumer was never invoked (`!header_sent && !header_failed`):
74+
/// deliver the one-shot `500` fallback header so the Java caller is never
75+
/// left without a header.
76+
FireFallbackHeader,
77+
/// The header was already committed (or its delivery threw): a panic now
78+
/// truncates the body past a committed header, so throw `IOException` to
79+
/// abort the response — symmetric with the body-error / sink-stop abort on
80+
/// the success branch.
81+
ThrowAbort,
82+
}
83+
84+
/// Decide the panic-branch action from the two header flags. Splitting it out
85+
/// (like [`should_fire_fallback_header`], which it reuses) keeps the decision
86+
/// unit-testable without a live JVM — see `jni_impl_streaming_abort_tests.rs`.
87+
pub(super) fn panic_post_header_action(
88+
header_sent: bool,
89+
header_failed: bool,
90+
) -> PanicHeaderAction {
91+
if should_fire_fallback_header(header_sent, header_failed) {
92+
PanicHeaderAction::FireFallbackHeader
93+
} else {
94+
PanicHeaderAction::ThrowAbort
95+
}
96+
}
97+
6198
/// Promoted refs + a checked-out chunk buffer for a response
6299
/// streaming-with-header dispatch. Aliased so the helper return type stays
63100
/// under clippy's `type_complexity` cap.

0 commit comments

Comments
 (0)