Skip to content

Commit 3f85700

Browse files
committed
Impl response body error
1 parent a6643de commit 3f85700

16 files changed

Lines changed: 494 additions & 65 deletions

File tree

crates/vespera_core/src/schema.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,4 +617,78 @@ mod tests {
617617
"must not contain 2.0: {json}"
618618
);
619619
}
620+
621+
// ── CORE-04: typed `additionalProperties` (untagged) ─────────────
622+
//
623+
// The untagged enum MUST serialize to the bare JSON Schema wire form
624+
// (a `true`/`false` or the schema object/`$ref`) — byte-identical to
625+
// the previous `serde_json::Value` representation — and round-trip
626+
// back to the right variant. Untagged deserialization is
627+
// order-sensitive, so these lock the contract.
628+
629+
#[test]
630+
fn additional_properties_bool_serializes_bare() {
631+
let schema = Schema {
632+
additional_properties: Some(AdditionalProperties::Bool(false)),
633+
..Schema::object()
634+
};
635+
let json = serde_json::to_string(&schema).unwrap();
636+
assert!(
637+
json.contains("\"additionalProperties\":false"),
638+
"bool must serialize as a bare boolean, got: {json}"
639+
);
640+
}
641+
642+
#[test]
643+
fn additional_properties_schema_ref_serializes_as_ref() {
644+
let schema = Schema {
645+
additional_properties: Some(AdditionalProperties::Schema(SchemaRef::Ref(
646+
Reference::schema("User"),
647+
))),
648+
..Schema::object()
649+
};
650+
let json = serde_json::to_string(&schema).unwrap();
651+
assert!(
652+
json.contains("\"additionalProperties\":{\"$ref\":\"#/components/schemas/User\"}"),
653+
"schema-ref must serialize as a bare $ref object, got: {json}"
654+
);
655+
}
656+
657+
#[test]
658+
fn additional_properties_roundtrips_each_variant() {
659+
// bool → Bool
660+
let v: AdditionalProperties = serde_json::from_str("true").unwrap();
661+
assert!(matches!(v, AdditionalProperties::Bool(true)));
662+
// {"$ref":...} → Schema(Ref)
663+
let v: AdditionalProperties =
664+
serde_json::from_str(r##"{"$ref":"#/components/schemas/X"}"##).unwrap();
665+
assert!(matches!(v, AdditionalProperties::Schema(SchemaRef::Ref(_))));
666+
// inline schema object → Schema(Inline)
667+
let v: AdditionalProperties = serde_json::from_str(r#"{"type":"string"}"#).unwrap();
668+
assert!(matches!(
669+
v,
670+
AdditionalProperties::Schema(SchemaRef::Inline(_))
671+
));
672+
}
673+
674+
// ── CORE-03: nullable-reference constructor ──────────────────────
675+
676+
#[test]
677+
fn nullable_reference_emits_ref_plus_nullable_only() {
678+
let schema = Schema::nullable_reference("#/components/schemas/User".to_owned());
679+
let json = serde_json::to_string(&schema).unwrap();
680+
assert!(
681+
json.contains("\"$ref\":\"#/components/schemas/User\""),
682+
"must carry the $ref: {json}"
683+
);
684+
assert!(
685+
json.contains("\"nullable\":true"),
686+
"must be nullable: {json}"
687+
);
688+
// schema_type stays None so no stray `"type"` is emitted alongside.
689+
assert!(
690+
!json.contains("\"type\":"),
691+
"a nullable reference must not also emit a type: {json}"
692+
);
693+
}
620694
}

crates/vespera_inprocess/benches/dispatch.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ fn bench_direct_write_path(c: &mut Criterion) {
402402

403403
/// P2 isolation (within-run A/B): default-app resolution via the
404404
/// lock-free `OnceLock` fast path vs named-app resolution through the
405-
/// `RwLock<HashMap>` slow path. Identical router, identical wire
405+
/// lock-free `ArcSwap` load (INP-07). Identical router, identical wire
406406
/// request shape — the only difference is the `"app"` header field.
407407
fn bench_resolve_path(c: &mut Criterion) {
408408
static INIT_NAMED: std::sync::Once = std::sync::Once::new();
@@ -434,11 +434,14 @@ fn bench_resolve_path(c: &mut Criterion) {
434434
/// many OS threads against one shared multi-thread runtime.
435435
///
436436
/// `default` resolves through the lock-free `OnceLock` fast path;
437-
/// `named` goes through the `RwLock<HashMap>`. Under reader pressure
438-
/// the RwLock path can park threads — the delta between the two
439-
/// captures exactly what the single-threaded `resolve_path` group
440-
/// cannot. Excluded from the CI regression gate (heavily
441-
/// scheduler-dependent); run locally for the numbers.
437+
/// `named` resolves through the lock-free `ArcSwap` load (INP-07).
438+
/// Both stay lock-free under reader pressure — the residual delta is
439+
/// the `OnceLock` single-atomic-load advantage over the `ArcSwap`
440+
/// load-plus-hash-lookup, which the single-threaded `resolve_path`
441+
/// group cannot isolate. See `registry_ab` for the RwLock-vs-ArcSwap
442+
/// before/after.
443+
/// Excluded from the CI regression gate (heavily scheduler-dependent);
444+
/// run locally for the numbers.
442445
fn bench_contended_path(c: &mut Criterion) {
443446
static INIT_NAMED: std::sync::Once = std::sync::Once::new();
444447

crates/vespera_inprocess/src/registry.rs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ static APP_ROUTERS: LazyLock<ArcSwap<HashMap<String, Router>>> =
4242
///
4343
/// The overwhelmingly common dispatch case is a wire header without
4444
/// an `"app"` field — routing to [`DEFAULT_APP_NAME`]. Resolving it
45-
/// through `APP_ROUTERS` costs an `RwLock` read acquisition per
46-
/// request, which parks threads under high concurrency. This
47-
/// `OnceLock` mirror is set (exactly once, inside the registration
48-
/// write lock so it can never diverge from the map) by the first
49-
/// successful `_default` registration and read with a single atomic
50-
/// load + `Router::clone` (`Arc` refcount bump) on every dispatch.
51-
///
52-
/// Named apps keep using the `RwLock<HashMap>` — they are the rare
53-
/// multi-app case and can be registered at any time.
45+
/// through `APP_ROUTERS` still costs an `ArcSwap` load + hash lookup
46+
/// per request. This `OnceLock` mirror is set (exactly once, by the
47+
/// first successful `_default` registration so it can never diverge
48+
/// from the map) and read with a single atomic load + `Router::clone`
49+
/// (`Arc` refcount bump) on every dispatch — skipping even the hash
50+
/// lookup.
51+
///
52+
/// Named apps resolve through the lock-free [`ArcSwap`] load — they are
53+
/// the rare multi-app case and can be registered at any time.
5454
static DEFAULT_ROUTER: OnceLock<Router> = OnceLock::new();
5555

5656
/// Validate an app name for registration / lookup.
@@ -119,9 +119,9 @@ where
119119
///
120120
/// # Panic safety
121121
///
122-
/// The `factory` closure is invoked **outside** the internal
123-
/// `RwLock`'s write guard. A panic in `factory` cannot poison the
124-
/// map; the registration is simply discarded and the slot remains
122+
/// The `factory` closure is invoked **outside** the [`ArcSwap`]
123+
/// copy-on-write update. A panic in `factory` cannot corrupt the
124+
/// registry; the registration is simply discarded and the slot remains
125125
/// available for retry.
126126
///
127127
/// # Invalid names

crates/vespera_inprocess/tests/request_size_cap.rs

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55
//! unlimited behaviour). Both tests pin the same cap so they are
66
//! order-independent under the parallel test runner.
77
8+
use std::cell::{Cell, RefCell};
9+
use std::ops::ControlFlow;
10+
811
use serde_json::Value;
912
use tokio::runtime::Builder;
10-
use vespera_inprocess::{dispatch_from_bytes, set_max_request_bytes};
13+
use vespera_inprocess::{
14+
dispatch_from_bytes, dispatch_streaming_async, dispatch_streaming_with_header_async,
15+
set_max_request_bytes,
16+
};
1117

1218
/// Small enough that a tiny valid header passes but a padded request
1319
/// trips the cap.
@@ -19,15 +25,28 @@ fn ensure_cap() {
1925
let _ = set_max_request_bytes(CAP);
2026
}
2127

28+
/// Parse the JSON header out of a `[u32 BE len | header JSON | body]`
29+
/// wire response.
30+
fn parse_header_json(resp: &[u8]) -> Value {
31+
assert!(resp.len() >= 4, "wire response too short");
32+
let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize;
33+
serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON")
34+
}
35+
2236
fn dispatch(wire: Vec<u8>) -> Value {
2337
let runtime = Builder::new_current_thread()
2438
.enable_all()
2539
.build()
2640
.expect("build runtime");
27-
let resp = dispatch_from_bytes(wire, &runtime);
28-
assert!(resp.len() >= 4, "wire response too short");
29-
let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize;
30-
serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON")
41+
parse_header_json(&dispatch_from_bytes(wire, &runtime))
42+
}
43+
44+
fn block_on<F: std::future::Future>(fut: F) -> F::Output {
45+
Builder::new_current_thread()
46+
.enable_all()
47+
.build()
48+
.expect("build runtime")
49+
.block_on(fut)
3150
}
3251

3352
fn wire_with_body(body_len: usize) -> Vec<u8> {
@@ -66,3 +85,68 @@ fn within_limit_request_is_not_capped() {
6685
"a request within the cap must not be rejected as oversized"
6786
);
6887
}
88+
89+
// ── Streaming-path ingress cap (INP-01) ──────────────────────────────
90+
//
91+
// Response streaming still buffers the full *request* in memory, so it
92+
// must enforce the same cap as the buffered entry points — unlike
93+
// bidirectional streaming, which pulls the request chunk-by-chunk and
94+
// is intentionally exempt.
95+
96+
#[test]
97+
fn oversized_streaming_request_returns_413() {
98+
ensure_cap();
99+
let wire = wire_with_body(200);
100+
assert!(wire.len() > CAP);
101+
102+
let chunks = Cell::new(0usize);
103+
let header_bytes = block_on(dispatch_streaming_async(wire, |_chunk: &[u8]| {
104+
chunks.set(chunks.get() + 1);
105+
ControlFlow::Continue(())
106+
}));
107+
108+
let header = parse_header_json(&header_bytes);
109+
assert_eq!(
110+
header["status"].as_u64(),
111+
Some(413),
112+
"response streaming buffers the full request, so an over-cap request must be 413"
113+
);
114+
assert_eq!(
115+
chunks.get(),
116+
0,
117+
"a capped request must never stream body chunks"
118+
);
119+
}
120+
121+
#[test]
122+
fn oversized_streaming_with_header_request_returns_413() {
123+
ensure_cap();
124+
let wire = wire_with_body(200);
125+
assert!(wire.len() > CAP);
126+
127+
let header_seen: RefCell<Option<Vec<u8>>> = RefCell::new(None);
128+
let chunks = Cell::new(0usize);
129+
block_on(dispatch_streaming_with_header_async(
130+
wire,
131+
|header: &[u8]| *header_seen.borrow_mut() = Some(header.to_vec()),
132+
|_chunk: &[u8]| {
133+
chunks.set(chunks.get() + 1);
134+
ControlFlow::Continue(())
135+
},
136+
));
137+
138+
let header_bytes = header_seen
139+
.into_inner()
140+
.expect("the header callback must fire exactly once, even on the 413 cap path");
141+
let header = parse_header_json(&header_bytes);
142+
assert_eq!(
143+
header["status"].as_u64(),
144+
Some(413),
145+
"the 413 must be delivered through the header callback"
146+
);
147+
assert_eq!(
148+
chunks.get(),
149+
0,
150+
"a capped request must never stream body chunks"
151+
);
152+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//! Regression test for INP-04: a response body that errors mid-stream
2+
//! must surface as a `500` wire response — never the original status
3+
//! with a silently-truncated (empty) body.
4+
//!
5+
//! Runs in its own test binary because [`register_app`] is a
6+
//! process-global first-wins registration; isolating it keeps this
7+
//! erroring app from leaking into other integration tests.
8+
9+
use axum::body::Body;
10+
use axum::response::Response;
11+
use axum::routing::get;
12+
use futures_util::stream;
13+
use tokio::runtime::Builder;
14+
use vespera_inprocess::{Router, dispatch_from_bytes, register_app};
15+
16+
/// A `200 OK` whose body's only frame is an error — collecting it fails
17+
/// partway, which the buffered dispatch path must report as a `500`.
18+
async fn erroring_body() -> Response {
19+
let s =
20+
stream::once(async { Err::<bytes::Bytes, std::io::Error>(std::io::Error::other("boom")) });
21+
Response::new(Body::from_stream(s))
22+
}
23+
24+
fn assemble_wire(method: &str, path: &str) -> Vec<u8> {
25+
let header = format!(r#"{{"v":1,"method":"{method}","path":"{path}"}}"#);
26+
let mut wire = Vec::new();
27+
wire.extend_from_slice(&u32::try_from(header.len()).unwrap().to_be_bytes());
28+
wire.extend_from_slice(header.as_bytes());
29+
wire
30+
}
31+
32+
#[test]
33+
fn response_body_stream_error_becomes_500() {
34+
register_app(|| Router::new().route("/boom", get(erroring_body)));
35+
36+
let runtime = Builder::new_current_thread()
37+
.enable_all()
38+
.build()
39+
.expect("build runtime");
40+
let resp = dispatch_from_bytes(assemble_wire("GET", "/boom"), &runtime);
41+
42+
assert!(resp.len() >= 4, "wire response too short");
43+
let header_len = u32::from_be_bytes(resp[..4].try_into().unwrap()) as usize;
44+
let header: serde_json::Value =
45+
serde_json::from_slice(&resp[4..4 + header_len]).expect("response header JSON");
46+
assert_eq!(
47+
header["status"].as_u64(),
48+
Some(500),
49+
"a mid-stream response body error must become a 500, not a silent empty success \
50+
(the handler's 200 status must NOT be reported with a truncated body)"
51+
);
52+
}

crates/vespera_jni/src/jni_impl.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,17 @@ const _: () = assert!(DIRECT_UNREPRESENTABLE < -i32::MAX);
315315
/// `out_addr` must point to a writable region of at least `out_cap`
316316
/// bytes that stays valid for the duration of this call (a JNI
317317
/// direct buffer pinned by the live `JByteBuffer` local ref).
318+
/// Whether `[a0, a0+a_len)` and `[b0, b0+b_len)` overlap (addresses as
319+
/// `usize`). Used to reject aliasing `in_buf` / `out_buf` direct-buffer
320+
/// ranges in [`Java_..._dispatchDirect0`] before creating a shared `&[u8]`
321+
/// and an exclusive `&mut [u8]` over them (SEC-1). `saturating_add`
322+
/// keeps the bound arithmetic panic-free for any address.
323+
fn ranges_overlap(a0: usize, a_len: usize, b0: usize, b_len: usize) -> bool {
324+
let a1 = a0.saturating_add(a_len);
325+
let b1 = b0.saturating_add(b_len);
326+
a0 < b1 && b0 < a1
327+
}
328+
318329
fn write_response_to_out(out_addr: *mut u8, out_cap: usize, response: &[u8]) -> jint {
319330
if response.len() <= out_cap {
320331
// SAFETY: `response.len() <= out_cap` and the caller
@@ -383,6 +394,11 @@ fn write_response_to_out(out_addr: *mut u8, out_cap: usize, response: &[u8]) ->
383394
/// keeps the backing memory valid throughout and the borrow never
384395
/// escapes the `block_on`, so nothing borrowed from the buffer
385396
/// outlives the call.
397+
/// 4. `in_buf` and `out_buf` are proven **non-overlapping** (SEC-1)
398+
/// before the shared `&[u8]` / exclusive `&mut [u8]` are created, so
399+
/// they never alias the same memory; and `out_buf` is **writable**
400+
/// (the Java wrapper rejects read-only buffers — SEC-2), so the
401+
/// `&mut [u8]` write target is valid.
386402
#[unsafe(no_mangle)]
387403
pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchDirect0<'local>(
388404
mut unowned_env: EnvUnowned<'local>,
@@ -415,6 +431,23 @@ pub extern "system" fn Java_com_devfive_vespera_bridge_VesperaBridge_dispatchDir
415431
}
416432
};
417433

434+
// SEC-1: reject overlapping `in_buf` / `out_buf` ranges.
435+
// Below we create a shared `&[u8]` over the input and an
436+
// exclusive `&mut [u8]` over the output; if they alias the
437+
// same direct-buffer memory (the caller passed the same
438+
// buffer, or overlapping `slice()`/`duplicate()` views) that
439+
// is instant UB. The Java wrapper cannot detect this (it has
440+
// no native address), so the check lives here. `out_buf` is
441+
// writable by the wrapper's `isReadOnly()` guard (SEC-2), so
442+
// writing the error response into it is sound.
443+
if ranges_overlap(in_addr as usize, in_len, out_addr as usize, out_cap) {
444+
let err = vespera_inprocess::error_wire(
445+
400,
446+
"in_buf and out_buf must not overlap (aliasing would be undefined behavior)",
447+
);
448+
return Ok(write_response_to_out(out_addr, out_cap, &err));
449+
}
450+
418451
let dispatched = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
419452
// SAFETY: invariants 1–3 above. `in_addr..in_addr+in_len`
420453
// (`in_len <= in_cap`) is a readable region and

0 commit comments

Comments
 (0)