From c745ffebc7a6d0be908438ad6344832148b0892b Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Wed, 14 Oct 2026 11:57:07 -0700 Subject: [PATCH 1/4] feat(bali): add transport discovery and contain native boundary panics --- Cargo.toml | 2 +- crates/bemo-ffi/Cargo.toml | 2 + crates/bemo-ffi/src/guard.rs | 115 +++ crates/bemo-ffi/src/lib.rs | 1 + crates/bemo-ffi/src/transport.rs | 328 ++++--- crates/bemo/Cargo.toml | 2 + docs/architecture.md | 15 +- include/elide_transport.h | 7 + .../elide/bemo/transport/TransportNative.java | 9 + .../transport/TransportNativeDiscovery.java | 57 ++ .../transport/TransportNativeProvider.java | 19 + .../transport/bali/BaliTransportNative.java | 817 ++++++++++++++++++ .../bali/BaliTransportNativeProvider.java | 35 + .../bemo/transport/bali/package-info.java | 3 + ...ide.bemo.transport.TransportNativeProvider | 1 + .../transport/FfmTransportNativeProvider.java | 28 + ...ide.bemo.transport.TransportNativeProvider | 1 + tests/java/dev/elide/bemo/FfmContract.java | 26 + .../TransportNativeSelectionTest.java | 72 ++ tools/build.py | 45 +- tools/generate_exports.py | 11 +- 21 files changed, 1450 insertions(+), 146 deletions(-) create mode 100644 crates/bemo-ffi/src/guard.rs create mode 100644 packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeDiscovery.java create mode 100644 packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeProvider.java create mode 100644 packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java create mode 100644 packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNativeProvider.java create mode 100644 packages/bali/src/main/java/dev/elide/bemo/transport/bali/package-info.java create mode 100644 packages/bali/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider create mode 100644 packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNativeProvider.java create mode 100644 packages/ffm/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider create mode 100644 tests/java/dev/elide/bemo/transport/TransportNativeSelectionTest.java diff --git a/Cargo.toml b/Cargo.toml index 8b89c83..bc00713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ large_stack_arrays = "deny" [profile.release] lto = "thin" codegen-units = 1 -panic = "abort" +panic = "unwind" overflow-checks = true strip = "debuginfo" diff --git a/crates/bemo-ffi/Cargo.toml b/crates/bemo-ffi/Cargo.toml index 71ef728..bebe4c1 100644 --- a/crates/bemo-ffi/Cargo.toml +++ b/crates/bemo-ffi/Cargo.toml @@ -1,4 +1,6 @@ [package] +# Path-based embedders must inherit Bemo's workspace, not the consumer's. +workspace = "../.." name = "bemo-ffi" description = "Shared C ABI for Bemo FFM and Native Image bindings" version.workspace = true diff --git a/crates/bemo-ffi/src/guard.rs b/crates/bemo-ffi/src/guard.rs new file mode 100644 index 0000000..d172f95 --- /dev/null +++ b/crates/bemo-ffi/src/guard.rs @@ -0,0 +1,115 @@ +//! Panic containment at the process-wide C boundary. +//! +//! A panic may leave shared handle state partially updated. Later entries refuse admission; +//! resources are deliberately retained rather than attempting cleanup through damaged state. +//! Calls admitted before the poison is published may still finish. This is not rollback. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, Ordering}; + +// Transport errors -1..-11 and OS errors <= -1000 retain their existing meanings. +const PANIC: i32 = -900; +const POISONED: i32 = -901; + +pub(crate) static BOUNDARY: Boundary = Boundary::new(); + +pub(crate) struct Boundary { + poisoned: AtomicBool, +} + +pub(crate) trait Failure: Sized { + fn failure(status: i32) -> Self; +} + +impl Failure for i32 { + fn failure(status: i32) -> Self { + status + } +} +impl Failure for i64 { + fn failure(status: i32) -> Self { + i64::from(status) + } +} +impl Failure for u32 { + fn failure(_: i32) -> Self { + 0 + } +} +impl Failure for u64 { + fn failure(_: i32) -> Self { + 0 + } +} + +impl Boundary { + const fn new() -> Self { + Self { + poisoned: AtomicBool::new(false), + } + } + + pub(crate) fn call(&self, action: impl FnOnce() -> T) -> T { + if self.poisoned.load(Ordering::Acquire) { + return T::failure(POISONED); + } + match catch_unwind(AssertUnwindSafe(action)) { + Ok(result) => result, + Err(_) => { + self.poisoned.store(true, Ordering::Release); + T::failure(PANIC) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_values_and_contains_panic_before_poisoning() { + let boundary = Boundary::new(); + assert_eq!(boundary.call(|| 42_i32), 42); + assert_eq!(boundary.call(|| 123_u64), 123); + assert_eq!(boundary.call::(|| panic!("boundary probe")), PANIC); + assert_eq!(boundary.call::(|| panic!("must never run")), POISONED); + assert_eq!(boundary.call(|| 1_i64), i64::from(POISONED)); + assert_eq!(boundary.call(|| 1_u64), 0); + assert_eq!(boundary.call(|| 1_u32), 0); + } + + #[test] + fn a_handle_failure_returns_zero_and_poison_is_visible_across_threads() { + let boundary = Boundary::new(); + assert_eq!(boundary.call::(|| panic!("handle probe")), 0); + std::thread::scope(|scope| { + scope + .spawn(|| { + assert_eq!(boundary.call::(|| panic!("must never run")), POISONED); + }) + .join() + .unwrap(); + }); + } + + #[test] + fn unwinding_drops_owned_temporaries_before_returning() { + struct Dropped<'a>(&'a AtomicBool); + impl Drop for Dropped<'_> { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + let boundary = Boundary::new(); + let dropped = AtomicBool::new(false); + assert_eq!( + boundary.call::(|| { + let _temporary = Dropped(&dropped); + panic!("cleanup probe"); + }), + PANIC + ); + assert!(dropped.load(Ordering::Relaxed)); + } +} diff --git a/crates/bemo-ffi/src/lib.rs b/crates/bemo-ffi/src/lib.rs index 9445117..e6ac49c 100644 --- a/crates/bemo-ffi/src/lib.rs +++ b/crates/bemo-ffi/src/lib.rs @@ -54,4 +54,5 @@ mod tests { } } +mod guard; mod transport; diff --git a/crates/bemo-ffi/src/transport.rs b/crates/bemo-ffi/src/transport.rs index 0bf64b5..9255e9e 100644 --- a/crates/bemo-ffi/src/transport.rs +++ b/crates/bemo-ffi/src/transport.rs @@ -1,6 +1,7 @@ // @generated by tools/generate_exports.py; do not edit. //! C exports preserving Elide transport ABI 3. #![allow(clippy::too_many_arguments)] +use crate::guard::BOUNDARY; use bemo::abi::{BufferView, NativeEvent, ReceiveResult}; use std::ffi::c_void; /// Forward to [`bemo::abi::engine::elide_transport_engine_context_new`]. @@ -18,25 +19,27 @@ pub unsafe extern "C" fn elide_transport_engine_context_new( alpn: *const u8, alpn_length: u64, ) -> u64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { - bemo::abi::engine::elide_transport_engine_context_new( - workload, - flags, - certificates, - certificates_length, - key, - key_length, - alpn, - alpn_length, - ) - } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { + bemo::abi::engine::elide_transport_engine_context_new( + workload, + flags, + certificates, + certificates_length, + key, + key_length, + alpn, + alpn_length, + ) + } + }) } /// Forward to [`bemo::abi::engine::elide_transport_engine_context_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_engine_context_release(context: u64) -> i32 { - bemo::abi::engine::elide_transport_engine_context_release(context) + BOUNDARY.call(|| bemo::abi::engine::elide_transport_engine_context_release(context)) } /// Forward to [`bemo::abi::engine::elide_transport_engine_new`]. @@ -50,8 +53,10 @@ pub unsafe extern "C" fn elide_transport_engine_new( name: *const u8, name_length: u64, ) -> u64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::engine::elide_transport_engine_new(workload, context, name, name_length) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::engine::elide_transport_engine_new(workload, context, name, name_length) } + }) } /// Forward to [`bemo::abi::engine::elide_transport_engine_wrap`]. @@ -66,10 +71,12 @@ pub unsafe extern "C" fn elide_transport_engine_wrap( destination: *mut u8, destination_length: u64, ) -> i64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { - bemo::abi::engine::elide_transport_engine_wrap(engine, source, source_length, destination, destination_length) - } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { + bemo::abi::engine::elide_transport_engine_wrap(engine, source, source_length, destination, destination_length) + } + }) } /// Forward to [`bemo::abi::engine::elide_transport_engine_unwrap`]. @@ -85,23 +92,25 @@ pub unsafe extern "C" fn elide_transport_engine_unwrap( destination_length: u64, flags: u32, ) -> i64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { - bemo::abi::engine::elide_transport_engine_unwrap( - engine, - source, - source_length, - destination, - destination_length, - flags, - ) - } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { + bemo::abi::engine::elide_transport_engine_unwrap( + engine, + source, + source_length, + destination, + destination_length, + flags, + ) + } + }) } /// Forward to [`bemo::abi::engine::elide_transport_engine_control`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_engine_control(engine: u64, operation: u32) -> i64 { - bemo::abi::engine::elide_transport_engine_control(engine, operation) + BOUNDARY.call(|| bemo::abi::engine::elide_transport_engine_control(engine, operation)) } /// Forward to [`bemo::abi::engine::elide_transport_engine_info`]. @@ -116,32 +125,34 @@ pub unsafe extern "C" fn elide_transport_engine_info( output: *mut u8, capacity: u64, ) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::engine::elide_transport_engine_info(engine, kind, index, output, capacity) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::engine::elide_transport_engine_info(engine, kind, index, output, capacity) } + }) } /// Forward to [`bemo::abi::engine::elide_transport_engine_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_engine_release(engine: u64) -> i32 { - bemo::abi::engine::elide_transport_engine_release(engine) + BOUNDARY.call(|| bemo::abi::engine::elide_transport_engine_release(engine)) } /// Forward to [`bemo::abi::elide_transport_gzip_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_gzip_new(workload: u64, level: u32) -> u64 { - bemo::abi::elide_transport_gzip_new(workload, level) + BOUNDARY.call(|| bemo::abi::elide_transport_gzip_new(workload, level)) } /// Forward to [`bemo::abi::elide_transport_gzip_compress`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_gzip_compress(workload: u64, encoder: u64, input: u64) -> u64 { - bemo::abi::elide_transport_gzip_compress(workload, encoder, input) + BOUNDARY.call(|| bemo::abi::elide_transport_gzip_compress(workload, encoder, input)) } /// Forward to [`bemo::abi::elide_transport_gzip_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_gzip_release(encoder: u64) -> i32 { - bemo::abi::elide_transport_gzip_release(encoder) + BOUNDARY.call(|| bemo::abi::elide_transport_gzip_release(encoder)) } /// Forward to [`bemo::abi::elide_transport_socket_http`]. @@ -153,7 +164,7 @@ pub extern "C" fn elide_transport_socket_http( owner: u64, capacity: u64, ) -> i32 { - bemo::abi::elide_transport_socket_http(workload, driver, socket, owner, capacity) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_http(workload, driver, socket, owner, capacity)) } /// Forward to [`bemo::abi::elide_transport_socket_http_tls`]. @@ -166,49 +177,49 @@ pub extern "C" fn elide_transport_socket_http_tls( capacity: u64, context: u64, ) -> i32 { - bemo::abi::elide_transport_socket_http_tls(workload, driver, socket, owner, capacity, context) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_http_tls(workload, driver, socket, owner, capacity, context)) } /// Forward to [`bemo::abi::elide_transport_http_drain`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_drain(driver: u64) -> i32 { - bemo::abi::elide_transport_http_drain(driver) + BOUNDARY.call(|| bemo::abi::elide_transport_http_drain(driver)) } /// Forward to [`bemo::abi::elide_transport_http_retire`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_retire(driver: u64) -> i32 { - bemo::abi::elide_transport_http_retire(driver) + BOUNDARY.call(|| bemo::abi::elide_transport_http_retire(driver)) } /// Forward to [`bemo::abi::elide_transport_http_segment_release_retired`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_segment_release_retired(segment: u64) -> i32 { - bemo::abi::elide_transport_http_segment_release_retired(segment) + BOUNDARY.call(|| bemo::abi::elide_transport_http_segment_release_retired(segment)) } /// Forward to [`bemo::abi::elide_transport_http_method`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_method(exchange: u64) -> i32 { - bemo::abi::elide_transport_http_method(exchange) + BOUNDARY.call(|| bemo::abi::elide_transport_http_method(exchange)) } /// Forward to [`bemo::abi::elide_transport_http_version`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_version(exchange: u64) -> i32 { - bemo::abi::elide_transport_http_version(exchange) + BOUNDARY.call(|| bemo::abi::elide_transport_http_version(exchange)) } /// Forward to [`bemo::abi::elide_transport_http_header_count`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_header_count(exchange: u64) -> i32 { - bemo::abi::elide_transport_http_header_count(exchange) + BOUNDARY.call(|| bemo::abi::elide_transport_http_header_count(exchange)) } /// Forward to [`bemo::abi::elide_transport_http_keep_alive`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_keep_alive(exchange: u64) -> i32 { - bemo::abi::elide_transport_http_keep_alive(exchange) + BOUNDARY.call(|| bemo::abi::elide_transport_http_keep_alive(exchange)) } /// Forward to [`bemo::abi::elide_transport_http_view`]. @@ -217,8 +228,10 @@ pub extern "C" fn elide_transport_http_keep_alive(exchange: u64) -> i32 { /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_http_view`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_http_view(exchange: u64, kind: u32, index: u32, output: *mut u64) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_view(exchange, kind, index, output) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_http_view(exchange, kind, index, output) } + }) } /// Forward to [`bemo::abi::elide_transport_http_spans`]. @@ -227,8 +240,10 @@ pub unsafe extern "C" fn elide_transport_http_view(exchange: u64, kind: u32, ind /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_http_spans`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_http_spans(exchange: u64, output: *mut u32, capacity: u32) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_spans(exchange, output, capacity) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_http_spans(exchange, output, capacity) } + }) } /// Forward to [`bemo::abi::elide_transport_http_respond`]. @@ -246,8 +261,12 @@ pub unsafe extern "C" fn elide_transport_http_respond( body_length: u64, flags: u32, ) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_respond(driver, exchange, status, headers, count, body, body_length, flags) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { + bemo::abi::elide_transport_http_respond(driver, exchange, status, headers, count, body, body_length, flags) + } + }) } /// Forward to [`bemo::abi::elide_transport_http_chunk_prepare`]. @@ -256,8 +275,10 @@ pub unsafe extern "C" fn elide_transport_http_respond( /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_http_chunk_prepare`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_http_chunk_prepare(exchange: u64, capacity: u64, address: *mut u64) -> u64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_chunk_prepare(exchange, capacity, address) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_http_chunk_prepare(exchange, capacity, address) } + }) } /// Forward to [`bemo::abi::elide_transport_http_chunk_send`]. @@ -269,7 +290,7 @@ pub extern "C" fn elide_transport_http_chunk_send( length: u64, flags: u32, ) -> i32 { - bemo::abi::elide_transport_http_chunk_send(driver, exchange, buffer, length, flags) + BOUNDARY.call(|| bemo::abi::elide_transport_http_chunk_send(driver, exchange, buffer, length, flags)) } /// Forward to [`bemo::abi::elide_transport_http_head_release`]. @@ -278,8 +299,10 @@ pub extern "C" fn elide_transport_http_chunk_send( /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_http_head_release`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_http_head_release(head: u64) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_head_release(head) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_http_head_release(head) } + }) } /// Forward to [`bemo::abi::elide_transport_http_prepare`]. @@ -288,38 +311,40 @@ pub unsafe extern "C" fn elide_transport_http_head_release(head: u64) -> i32 { /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_http_prepare`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_http_prepare(exchange: u64, capacity: u64) -> u64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_prepare(exchange, capacity) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_http_prepare(exchange, capacity) } + }) } /// Forward to [`bemo::abi::elide_transport_http_send`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_send(driver: u64, exchange: u64, length: u64, flags: u32) -> i32 { - bemo::abi::elide_transport_http_send(driver, exchange, length, flags) + BOUNDARY.call(|| bemo::abi::elide_transport_http_send(driver, exchange, length, flags)) } /// Forward to [`bemo::abi::elide_transport_http_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_release(driver: u64, exchange: u64) -> i32 { - bemo::abi::elide_transport_http_release(driver, exchange) + BOUNDARY.call(|| bemo::abi::elide_transport_http_release(driver, exchange)) } /// Forward to [`bemo::abi::elide_transport_http_free`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_free(driver: u64, exchange: u64) -> i32 { - bemo::abi::elide_transport_http_free(driver, exchange) + BOUNDARY.call(|| bemo::abi::elide_transport_http_free(driver, exchange)) } /// Forward to [`bemo::abi::elide_transport_http_segment_ack`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_segment_ack(driver: u64, segment: u64) -> i32 { - bemo::abi::elide_transport_http_segment_ack(driver, segment) + BOUNDARY.call(|| bemo::abi::elide_transport_http_segment_ack(driver, segment)) } /// Forward to [`bemo::abi::elide_transport_http_segment_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_http_segment_release(driver: u64, segment: u64) -> i32 { - bemo::abi::elide_transport_http_segment_release(driver, segment) + BOUNDARY.call(|| bemo::abi::elide_transport_http_segment_release(driver, segment)) } /// Forward to [`bemo::abi::elide_transport_http_retain`]. @@ -328,32 +353,34 @@ pub extern "C" fn elide_transport_http_segment_release(driver: u64, segment: u64 /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_http_retain`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_http_retain(exchange: u64, length: *mut u64) -> u64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_http_retain(exchange, length) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_http_retain(exchange, length) } + }) } /// Forward to [`bemo::abi::elide_transport_serving_available_cores`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_available_cores() -> i32 { - bemo::abi::elide_transport_serving_available_cores() + BOUNDARY.call(bemo::abi::elide_transport_serving_available_cores) } /// Forward to [`bemo::abi::elide_transport_serving_context_enter`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_context_enter(application: u64, replica: u32) -> i32 { - bemo::abi::elide_transport_serving_context_enter(application, replica) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_context_enter(application, replica)) } /// Forward to [`bemo::abi::elide_transport_serving_context_leave`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_context_leave() -> i32 { - bemo::abi::elide_transport_serving_context_leave() + BOUNDARY.call(bemo::abi::elide_transport_serving_context_leave) } /// Forward to [`bemo::abi::elide_transport_serving_helper_prepare`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_helper_prepare() -> u64 { - bemo::abi::elide_transport_serving_helper_prepare() + BOUNDARY.call(bemo::abi::elide_transport_serving_helper_prepare) } /// Forward to [`bemo::abi::elide_transport_serving_helper_release`]. @@ -362,8 +389,10 @@ pub extern "C" fn elide_transport_serving_helper_prepare() -> u64 { /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_serving_helper_release`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_serving_helper_release(token: u64) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_serving_helper_release(token) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_serving_helper_release(token) } + }) } /// Forward to [`bemo::abi::elide_transport_serving_helper_start`]. @@ -372,26 +401,28 @@ pub unsafe extern "C" fn elide_transport_serving_helper_release(token: u64) -> i /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_serving_helper_start`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_serving_helper_start(token: u64) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_serving_helper_start(token) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_serving_helper_start(token) } + }) } /// Forward to [`bemo::abi::elide_transport_serving_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_new(count: u32) -> u64 { - bemo::abi::elide_transport_serving_new(count) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_new(count)) } /// Forward to [`bemo::abi::elide_transport_serving_split_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_split_new(count: u32) -> u64 { - bemo::abi::elide_transport_serving_split_new(count) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_split_new(count)) } /// Forward to [`bemo::abi::elide_transport_serving_workers`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_workers(application: u64, context: u32) -> i32 { - bemo::abi::elide_transport_serving_workers(application, context) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_workers(application, context)) } /// Forward to [`bemo::abi::elide_transport_serving_worker_driver`]. @@ -404,7 +435,8 @@ pub extern "C" fn elide_transport_serving_worker_driver( backend: u32, limit: u32, ) -> u64 { - bemo::abi::elide_transport_serving_worker_driver(workload, application, context, worker, backend, limit) + BOUNDARY + .call(|| bemo::abi::elide_transport_serving_worker_driver(workload, application, context, worker, backend, limit)) } /// Forward to [`bemo::abi::elide_transport_serving_driver`]. @@ -417,7 +449,7 @@ pub extern "C" fn elide_transport_serving_driver( limit: u32, affinity: u32, ) -> u64 { - bemo::abi::elide_transport_serving_driver(workload, application, index, backend, limit, affinity) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_driver(workload, application, index, backend, limit, affinity)) } /// Forward to [`bemo::abi::elide_transport_serving_listen`]. @@ -429,37 +461,37 @@ pub extern "C" fn elide_transport_serving_listen( signature: u64, backlog: i32, ) -> u64 { - bemo::abi::elide_transport_serving_listen(workload, driver, endpoint, signature, backlog) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_listen(workload, driver, endpoint, signature, backlog)) } /// Forward to [`bemo::abi::elide_transport_serving_ready`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_ready(driver: u64) -> i32 { - bemo::abi::elide_transport_serving_ready(driver) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_ready(driver)) } /// Forward to [`bemo::abi::elide_transport_serving_cpu`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_cpu(driver: u64) -> i32 { - bemo::abi::elide_transport_serving_cpu(driver) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_cpu(driver)) } /// Forward to [`bemo::abi::elide_transport_serving_listener_close`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_listener_close(driver: u64, listener: u64) -> i32 { - bemo::abi::elide_transport_serving_listener_close(driver, listener) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_listener_close(driver, listener)) } /// Forward to [`bemo::abi::elide_transport_serving_close`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_serving_close(application: u64) -> i32 { - bemo::abi::elide_transport_serving_close(application) + BOUNDARY.call(|| bemo::abi::elide_transport_serving_close(application)) } /// Forward to [`bemo::abi::elide_transport_last_error`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_last_error() -> i32 { - bemo::abi::elide_transport_last_error() + BOUNDARY.call(bemo::abi::elide_transport_last_error) } /// Forward to [`bemo::abi::elide_transport_socket_listen`]. @@ -471,43 +503,43 @@ pub extern "C" fn elide_transport_socket_listen( backlog: i32, reuse: u32, ) -> u64 { - bemo::abi::elide_transport_socket_listen(workload, driver, endpoint, backlog, reuse) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_listen(workload, driver, endpoint, backlog, reuse)) } /// Forward to [`bemo::abi::elide_transport_socket_connect`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_connect(workload: u64, driver: u64, endpoint: u64) -> u64 { - bemo::abi::elide_transport_socket_connect(workload, driver, endpoint) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_connect(workload, driver, endpoint)) } /// Forward to [`bemo::abi::elide_transport_socket_accept`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_accept(workload: u64, driver: u64, listener: u64) -> u64 { - bemo::abi::elide_transport_socket_accept(workload, driver, listener) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_accept(workload, driver, listener)) } /// Forward to [`bemo::abi::elide_transport_socket_adopt`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_adopt(workload: u64, driver: u64, accepted: u64) -> i32 { - bemo::abi::elide_transport_socket_adopt(workload, driver, accepted) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_adopt(workload, driver, accepted)) } /// Forward to [`bemo::abi::elide_transport_socket_discard`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_discard(accepted: u64) -> i32 { - bemo::abi::elide_transport_socket_discard(accepted) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_discard(accepted)) } /// Forward to [`bemo::abi::elide_transport_socket_address`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_address(driver: u64, socket: u64, peer: u32, output: u64) -> i32 { - bemo::abi::elide_transport_socket_address(driver, socket, peer, output) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_address(driver, socket, peer, output)) } /// Forward to [`bemo::abi::elide_transport_socket_receive`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_receive(workload: u64, driver: u64, socket: u64, buffer: u64) -> u64 { - bemo::abi::elide_transport_socket_receive(workload, driver, socket, buffer) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_receive(workload, driver, socket, buffer)) } /// Forward to [`bemo::abi::elide_transport_socket_receive_new`]. @@ -519,7 +551,7 @@ pub extern "C" fn elide_transport_socket_receive_new( owner: u64, capacity: u64, ) -> u64 { - bemo::abi::elide_transport_socket_receive_new(workload, driver, socket, owner, capacity) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_receive_new(workload, driver, socket, owner, capacity)) } /// Forward to [`bemo::abi::elide_transport_socket_receive_new_result`]. @@ -535,8 +567,10 @@ pub unsafe extern "C" fn elide_transport_socket_receive_new_result( capacity: u64, output: *mut ReceiveResult, ) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_socket_receive_new_result(workload, driver, socket, owner, capacity, output) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_socket_receive_new_result(workload, driver, socket, owner, capacity, output) } + }) } /// Forward to [`bemo::abi::elide_transport_socket_send`]. @@ -549,7 +583,7 @@ pub extern "C" fn elide_transport_socket_send( offset: u64, length: u64, ) -> u64 { - bemo::abi::elide_transport_socket_send(workload, driver, socket, buffer, offset, length) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_send(workload, driver, socket, buffer, offset, length)) } /// Forward to [`bemo::abi::elide_transport_socket_send_inline`]. @@ -564,8 +598,10 @@ pub unsafe extern "C" fn elide_transport_socket_send_inline( source: *const u8, length: u64, ) -> i64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_socket_send_inline(workload, driver, socket, source, length) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_socket_send_inline(workload, driver, socket, source, length) } + }) } /// Forward to [`bemo::abi::elide_transport_socket_send_inline_vectored`]. @@ -580,8 +616,10 @@ pub unsafe extern "C" fn elide_transport_socket_send_inline_vectored( regions: *const u64, count: u32, ) -> i64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_socket_send_inline_vectored(workload, driver, socket, regions, count) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_socket_send_inline_vectored(workload, driver, socket, regions, count) } + }) } /// Forward to [`bemo::abi::elide_transport_socket_send_gathered`]. @@ -596,26 +634,28 @@ pub unsafe extern "C" fn elide_transport_socket_send_gathered( regions: *const u64, count: u32, ) -> u64 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_socket_send_gathered(workload, driver, socket, regions, count) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_socket_send_gathered(workload, driver, socket, regions, count) } + }) } /// Forward to [`bemo::abi::elide_transport_socket_close`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_close(driver: u64, socket: u64) -> i32 { - bemo::abi::elide_transport_socket_close(driver, socket) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_close(driver, socket)) } /// Forward to [`bemo::abi::elide_transport_socket_option`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_option(driver: u64, socket: u64, option: u32, value: i32) -> i32 { - bemo::abi::elide_transport_socket_option(driver, socket, option, value) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_option(driver, socket, option, value)) } /// Forward to [`bemo::abi::elide_transport_socket_shutdown`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_socket_shutdown(driver: u64, socket: u64, direction: u32) -> i32 { - bemo::abi::elide_transport_socket_shutdown(driver, socket, direction) + BOUNDARY.call(|| bemo::abi::elide_transport_socket_shutdown(driver, socket, direction)) } /// Forward to [`bemo::abi::elide_transport_driver_poll`]. @@ -624,8 +664,10 @@ pub extern "C" fn elide_transport_socket_shutdown(driver: u64, socket: u64, dire /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_driver_poll`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_driver_poll(driver: u64, timeout_ns: u64, batch: u64, maximum: u32) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_driver_poll(driver, timeout_ns, batch, maximum) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_driver_poll(driver, timeout_ns, batch, maximum) } + }) } /// Forward to [`bemo::abi::elide_transport_driver_poll_callback`]. @@ -641,8 +683,10 @@ pub unsafe extern "C" fn elide_transport_driver_poll_callback( callback: Option i32>, context: *mut c_void, ) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_driver_poll_callback(workload, driver, timeout_ns, maximum, callback, context) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_driver_poll_callback(workload, driver, timeout_ns, maximum, callback, context) } + }) } /// Forward to [`bemo::abi::elide_transport_driver_poll_batch_callback`]. @@ -658,40 +702,42 @@ pub unsafe extern "C" fn elide_transport_driver_poll_batch_callback( callback: Option i32>, context: *mut c_void, ) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { - bemo::abi::elide_transport_driver_poll_batch_callback(workload, driver, timeout_ns, maximum, callback, context) - } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { + bemo::abi::elide_transport_driver_poll_batch_callback(workload, driver, timeout_ns, maximum, callback, context) + } + }) } /// Forward to [`bemo::abi::elide_transport_tls_client`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_client(workload: u64, roots: u64, alpn: u64) -> u64 { - bemo::abi::elide_transport_tls_client(workload, roots, alpn) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_client(workload, roots, alpn)) } /// Forward to [`bemo::abi::elide_transport_tls_server`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_server(workload: u64, chain: u64, key: u64, alpn: u64) -> u64 { - bemo::abi::elide_transport_tls_server(workload, chain, key, alpn) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_server(workload, chain, key, alpn)) } /// Forward to [`bemo::abi::elide_transport_tls_context_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_context_release(context: u64) -> i32 { - bemo::abi::elide_transport_tls_context_release(context) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_context_release(context)) } /// Forward to [`bemo::abi::elide_transport_tls_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_new(workload: u64, context: u64, owner: u64, name: u64) -> u64 { - bemo::abi::elide_transport_tls_new(workload, context, owner, name) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_new(workload, context, owner, name)) } /// Forward to [`bemo::abi::elide_transport_tls_feed`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_feed(session: u64, input: u64, length: u64) -> i32 { - bemo::abi::elide_transport_tls_feed(session, input, length) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_feed(session, input, length)) } /// Forward to [`bemo::abi::elide_transport_tls_step`]. @@ -704,55 +750,55 @@ pub extern "C" fn elide_transport_tls_step( length: u64, output: u64, ) -> i32 { - bemo::abi::elide_transport_tls_step(session, action, plaintext, offset, length, output) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_step(session, action, plaintext, offset, length, output)) } /// Forward to [`bemo::abi::elide_transport_tls_protocol`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_protocol(session: u64, output: u64) -> i32 { - bemo::abi::elide_transport_tls_protocol(session, output) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_protocol(session, output)) } /// Forward to [`bemo::abi::elide_transport_tls_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_tls_release(session: u64) -> i32 { - bemo::abi::elide_transport_tls_release(session) + BOUNDARY.call(|| bemo::abi::elide_transport_tls_release(session)) } /// Forward to [`bemo::abi::elide_transport_abi_version`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_abi_version() -> u32 { - bemo::abi::elide_transport_abi_version() + BOUNDARY.call(bemo::abi::elide_transport_abi_version) } /// Forward to [`bemo::abi::elide_transport_owner_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_owner_new(limit: u64) -> u64 { - bemo::abi::elide_transport_owner_new(limit) + BOUNDARY.call(|| bemo::abi::elide_transport_owner_new(limit)) } /// Forward to [`bemo::abi::elide_transport_owner_used`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_owner_used(owner: u64) -> u64 { - bemo::abi::elide_transport_owner_used(owner) + BOUNDARY.call(|| bemo::abi::elide_transport_owner_used(owner)) } /// Forward to [`bemo::abi::elide_transport_owner_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_owner_release(owner: u64) -> i32 { - bemo::abi::elide_transport_owner_release(owner) + BOUNDARY.call(|| bemo::abi::elide_transport_owner_release(owner)) } /// Forward to [`bemo::abi::elide_transport_workload_close`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_workload_close(workload: u64) -> i32 { - bemo::abi::elide_transport_workload_close(workload) + BOUNDARY.call(|| bemo::abi::elide_transport_workload_close(workload)) } /// Forward to [`bemo::abi::elide_transport_buffer_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_buffer_new(owner: u64, capacity: u64) -> u64 { - bemo::abi::elide_transport_buffer_new(owner, capacity) + BOUNDARY.call(|| bemo::abi::elide_transport_buffer_new(owner, capacity)) } /// Forward to [`bemo::abi::elide_transport_buffer_view`]. @@ -761,8 +807,10 @@ pub extern "C" fn elide_transport_buffer_new(owner: u64, capacity: u64) -> u64 { /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_buffer_view`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_buffer_view(handle: u64, output: *mut BufferView) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_buffer_view(handle, output) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_buffer_view(handle, output) } + }) } /// Forward to [`bemo::abi::elide_transport_buffer_freeze`]. @@ -771,48 +819,50 @@ pub unsafe extern "C" fn elide_transport_buffer_view(handle: u64, output: *mut B /// The caller must satisfy the safety contract of [`bemo::abi::elide_transport_buffer_freeze`]. #[unsafe(no_mangle)] pub unsafe extern "C" fn elide_transport_buffer_freeze(handle: u64, length: u64) -> i32 { - // SAFETY: the exported contract is identical to the Rust handle API. - unsafe { bemo::abi::elide_transport_buffer_freeze(handle, length) } + BOUNDARY.call(|| { + // SAFETY: the exported contract is identical to the Rust handle API. + unsafe { bemo::abi::elide_transport_buffer_freeze(handle, length) } + }) } /// Forward to [`bemo::abi::elide_transport_buffer_slice`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_buffer_slice(handle: u64, offset: u64, length: u64) -> u64 { - bemo::abi::elide_transport_buffer_slice(handle, offset, length) + BOUNDARY.call(|| bemo::abi::elide_transport_buffer_slice(handle, offset, length)) } /// Forward to [`bemo::abi::elide_transport_buffer_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_buffer_release(handle: u64) -> i32 { - bemo::abi::elide_transport_buffer_release(handle) + BOUNDARY.call(|| bemo::abi::elide_transport_buffer_release(handle)) } /// Forward to [`bemo::abi::elide_transport_driver_new`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_driver_new(workload: u64, backend: u32, limit: u32) -> u64 { - bemo::abi::elide_transport_driver_new(workload, backend, limit) + BOUNDARY.call(|| bemo::abi::elide_transport_driver_new(workload, backend, limit)) } /// Forward to [`bemo::abi::elide_transport_driver_backend`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_driver_backend(handle: u64) -> i32 { - bemo::abi::elide_transport_driver_backend(handle) + BOUNDARY.call(|| bemo::abi::elide_transport_driver_backend(handle)) } /// Forward to [`bemo::abi::elide_transport_driver_fallback`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_driver_fallback(handle: u64, output: u64) -> i32 { - bemo::abi::elide_transport_driver_fallback(handle, output) + BOUNDARY.call(|| bemo::abi::elide_transport_driver_fallback(handle, output)) } /// Forward to [`bemo::abi::elide_transport_driver_wake`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_driver_wake(handle: u64) -> i32 { - bemo::abi::elide_transport_driver_wake(handle) + BOUNDARY.call(|| bemo::abi::elide_transport_driver_wake(handle)) } /// Forward to [`bemo::abi::elide_transport_driver_release`]. #[unsafe(no_mangle)] pub extern "C" fn elide_transport_driver_release(handle: u64) -> i32 { - bemo::abi::elide_transport_driver_release(handle) + BOUNDARY.call(|| bemo::abi::elide_transport_driver_release(handle)) } diff --git a/crates/bemo/Cargo.toml b/crates/bemo/Cargo.toml index 96e41b8..621c58f 100644 --- a/crates/bemo/Cargo.toml +++ b/crates/bemo/Cargo.toml @@ -1,4 +1,6 @@ [package] +# Path-based embedders must inherit Bemo's workspace, not the consumer's. +workspace = "../.." name = "bemo" description = "Runtime-independent native transport core for Netty and Elide" version.workspace = true diff --git a/docs/architecture.md b/docs/architecture.md index 33139ff..d307b0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -217,8 +217,19 @@ not promise that a particular OS backend is available. C layouts use fixed-width fields. Generated exports are checked against the Rust signatures and public headers. Native Image imports are generated from the -same interface description. Rust panics cannot unwind through C calls; a panic -at this boundary terminates the process. +same interface description. Generated C exports contain Rust panics with +`catch_unwind`; release builds use `panic = "unwind"`. A failing signed call +returns -900 (PANIC), and subsequent entries return -901 (POISONED) without +accessing driver or handle state. Unsigned handle/count calls return zero. +Metadata ABI/capability queries remain constant. Poison is process-wide because +an operation may have partially changed shared state. Already admitted calls +may finish; borrowed output records are unspecified on failure. Containment +retains potentially damaged resources rather than attempting repair or release. +Allocation failure and aborting dependencies are outside unwind containment. + +`bemo-bali` provides direct image-owned guest API calls selected through +`TransportNativeDiscovery`; ordinary JVMs fall back to the FFM provider. +The API and FFM artifacts have no dependency on Bali runtime types. ## Building and checking changes diff --git a/include/elide_transport.h b/include/elide_transport.h index d9d8801..34e19ac 100644 --- a/include/elide_transport.h +++ b/include/elide_transport.h @@ -2,6 +2,13 @@ #ifndef ELIDE_TRANSPORT_H #define ELIDE_TRANSPORT_H +/* C boundary failures: signed results return PANIC once on the failing call, + * then POISONED on subsequent admission; unsigned handle/count results return 0. + * Calls already in flight may finish. Borrowed outputs are unspecified on failure. */ +#define ELIDE_TRANSPORT_PANIC (-900) +#define ELIDE_TRANSPORT_POISONED (-901) + + #include #ifdef __cplusplus diff --git a/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java index 460246b..514bcc9 100644 --- a/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java +++ b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java @@ -21,6 +21,15 @@ * it, and closing a workload cancels only its own operations. */ public interface TransportNative { + /** + * Selects a compatible registered backend, preferring the embedding's direct implementation. A + * null result lets framework adapters retain their stock transport. Native handles remain owned + * by the caller; discovery creates no workload or driver. + */ + static @Nullable TransportNative load() { + return TransportNativeDiscovery.load(); + } + /** Preserved transport ABI version, independent of Bemo metadata ABI 1. */ int ABI_VERSION = 3; diff --git a/packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeDiscovery.java b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeDiscovery.java new file mode 100644 index 0000000..f68da34 --- /dev/null +++ b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeDiscovery.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024-2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.transport; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.ServiceLoader; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + +/** Backend discovery is configuration work, never part of a request's native crossing. */ +final class TransportNativeDiscovery { + private static final AtomicBoolean REPORTED = new AtomicBoolean(); + + private TransportNativeDiscovery() {} + + static @Nullable TransportNative load() { + return choose( + ServiceLoader.load(TransportNativeProvider.class, TransportNative.class.getClassLoader()), + message -> { + if (REPORTED.compareAndSet(false, true)) System.err.println(message); + }); + } + + static @Nullable TransportNative choose( + Iterable candidates, Consumer diagnostic) { + var providers = new ArrayList(); + candidates.forEach(providers::add); + providers.sort(Comparator.comparingInt(TransportNativeProvider::priority).reversed()); + for (var provider : providers) { + try { + var backend = provider.open(); + if (backend == null) continue; + int version = backend.version(); + if (version != TransportNative.ABI_VERSION) { + throw new LinkageError( + "transport ABI mismatch: expected " + + TransportNative.ABI_VERSION + + ", got " + + version); + } + return backend; + } catch (LinkageError | UnsupportedOperationException failure) { + diagnostic.accept( + "Bemo: " + + provider.getClass().getName() + + " unavailable; trying the next backend (" + + failure.getMessage() + + ")"); + } + } + return null; + } +} diff --git a/packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeProvider.java b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeProvider.java new file mode 100644 index 0000000..150a589 --- /dev/null +++ b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNativeProvider.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2024-2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.transport; + +import org.jspecify.annotations.Nullable; + +/** Optional backend discovered without adding runtime dependencies to bemo-api. */ +public interface TransportNativeProvider { + /** Higher priority selects an embedding's direct backend before portable FFM. */ + int priority(); + + /** + * Opens an available backend after validating both metadata and transport ABI versions. Returns + * null when the embedding is absent; a linkage refusal permits the next provider. + */ + @Nullable TransportNative open(); +} diff --git a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java new file mode 100644 index 0000000..0b707de --- /dev/null +++ b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java @@ -0,0 +1,817 @@ +/* + * Copyright (c) 2024-2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.transport.bali; + +import bali.api.BemoNative; +import dev.elide.bemo.transport.TransportNative; +import java.nio.ByteBuffer; + +/** Direct image calls; the guest retains its own Bemo and framework classes. */ +public final class BaliTransportNative implements TransportNative { + private static int servingAvailableCores0() { + return BemoNative.elide_transport_serving_available_cores(); + } + + @Override + public int servingAvailableCores() { + return servingAvailableCores0(); + } + + private static int servingContextEnter0(long application, int replica) { + return BemoNative.elide_transport_serving_context_enter(application, replica); + } + + @Override + public int servingContextEnter(long application, int replica) { + return servingContextEnter0(application, replica); + } + + private static int servingContextLeave0() { + return BemoNative.elide_transport_serving_context_leave(); + } + + @Override + public int servingContextLeave() { + return servingContextLeave0(); + } + + @Override + public long servingShardDriver( + long workload, long application, int replica, int backend, int limit) { + return servingDriver0(workload, application, replica, backend, limit, 1); + } + + private static long servingNew0(int contexts) { + return BemoNative.elide_transport_serving_new(contexts); + } + + private static long servingSplitNew0(int contexts) { + return BemoNative.elide_transport_serving_split_new(contexts); + } + + @Override + public long servingSplitNew(int contexts) { + return servingSplitNew0(contexts); + } + + private static int servingWorkers0(long application, int context) { + return BemoNative.elide_transport_serving_workers(application, context); + } + + @Override + public int servingWorkers(long application, int context) { + return servingWorkers0(application, context); + } + + private static long servingWorkerDriver0( + long workload, long application, int context, int worker, int backend, int limit) { + return BemoNative.elide_transport_serving_worker_driver( + workload, application, context, worker, backend, limit); + } + + @Override + public long servingWorkerDriver( + long workload, long application, int context, int worker, int backend, int limit) { + return servingWorkerDriver0(workload, application, context, worker, backend, limit); + } + + @Override + public long servingNew(int contexts) { + return servingNew0(contexts); + } + + private static long servingDriver0( + long workload, long application, int replica, int backend, int limit, int affinity) { + return BemoNative.elide_transport_serving_driver( + workload, application, replica, backend, limit, affinity); + } + + @Override + public long servingDriver(long workload, long application, int replica, int backend, int limit) { + return servingDriver0(workload, application, replica, backend, limit, 1); + } + + @Override + public long servingSplitDriver( + long workload, long application, int replica, int backend, int limit) { + return servingDriver0(workload, application, replica, backend, limit, 2); + } + + private static long servingListen0( + long workload, long driver, long endpoint, long signature, int backlog) { + return BemoNative.elide_transport_serving_listen( + workload, driver, endpoint, signature, backlog); + } + + @Override + public long servingListen( + long workload, long driver, long endpoint, long signature, int backlog) { + return servingListen0(workload, driver, endpoint, signature, backlog); + } + + private static int servingReady0(long driver) { + return BemoNative.elide_transport_serving_ready(driver); + } + + @Override + public int servingReady(long driver) { + return servingReady0(driver); + } + + private static int servingCpu0(long driver) { + return BemoNative.elide_transport_serving_cpu(driver); + } + + @Override + public int servingCpu(long driver) { + return servingCpu0(driver); + } + + private static int servingListenerClose0(long driver, long listener) { + return BemoNative.elide_transport_serving_listener_close(driver, listener); + } + + @Override + public int servingListenerClose(long driver, long listener) { + return servingListenerClose0(driver, listener); + } + + private static int servingClose0(long application) { + return BemoNative.elide_transport_serving_close(application); + } + + @Override + public int servingClose(long application) { + return servingClose0(application); + } + + private static int lastError0() { + return BemoNative.elide_transport_last_error(); + } + + @Override + public int lastError() { + return lastError0(); + } + + private static int version0() { + return BemoNative.elide_transport_abi_version(); + } + + @Override + public int version() { + return version0(); + } + + private static long ownerNew0(long limit) { + return BemoNative.elide_transport_owner_new(limit); + } + + @Override + public long ownerNew(long limit) { + return ownerNew0(limit); + } + + private static long ownerUsed0(long owner) { + return BemoNative.elide_transport_owner_used(owner); + } + + @Override + public long ownerUsed(long owner) { + return ownerUsed0(owner); + } + + private static int ownerRelease0(long owner) { + return BemoNative.elide_transport_owner_release(owner); + } + + @Override + public int ownerRelease(long owner) { + return ownerRelease0(owner); + } + + private static int workloadClose0(long workload) { + return BemoNative.elide_transport_workload_close(workload); + } + + @Override + public int workloadClose(long workload) { + return workloadClose0(workload); + } + + private static long bufferNew0(long owner, long capacity) { + return BemoNative.elide_transport_buffer_new(owner, capacity); + } + + @Override + public long bufferNew(long owner, long capacity) { + return bufferNew0(owner, capacity); + } + + private static int bufferFreeze0(long buffer, long length) { + return BemoNative.elide_transport_buffer_freeze(buffer, length); + } + + @Override + public int bufferFreeze(long buffer, long length) { + return bufferFreeze0(buffer, length); + } + + private static long bufferSlice0(long buffer, long offset, long length) { + return BemoNative.elide_transport_buffer_slice(buffer, offset, length); + } + + @Override + public long bufferSlice(long buffer, long offset, long length) { + return bufferSlice0(buffer, offset, length); + } + + private static int bufferRelease0(long buffer) { + return BemoNative.elide_transport_buffer_release(buffer); + } + + @Override + public int bufferRelease(long buffer) { + return bufferRelease0(buffer); + } + + private static long driverNew0(long workload, int backend, int limit) { + return BemoNative.elide_transport_driver_new(workload, backend, limit); + } + + @Override + public long driverNew(long workload, int backend, int limit) { + return driverNew0(workload, backend, limit); + } + + private static int driverBackend0(long driver) { + return BemoNative.elide_transport_driver_backend(driver); + } + + @Override + public int driverBackend(long driver) { + return driverBackend0(driver); + } + + private static int driverWake0(long driver) { + return BemoNative.elide_transport_driver_wake(driver); + } + + @Override + public int driverWake(long driver) { + return driverWake0(driver); + } + + private static int driverRelease0(long driver) { + return BemoNative.elide_transport_driver_release(driver); + } + + @Override + public int driverRelease(long driver) { + return driverRelease0(driver); + } + + private static int driverFallback0(long driver, long output) { + return BemoNative.elide_transport_driver_fallback(driver, output); + } + + @Override + public int driverFallback(long driver, long output) { + return driverFallback0(driver, output); + } + + private static long socketListen0( + long workload, long driver, long endpoint, int backlog, int reuse) { + return BemoNative.elide_transport_socket_listen(workload, driver, endpoint, backlog, reuse); + } + + @Override + public long socketListen(long workload, long driver, long endpoint, int backlog, int reuse) { + return socketListen0(workload, driver, endpoint, backlog, reuse); + } + + private static long socketConnect0(long workload, long driver, long endpoint) { + return BemoNative.elide_transport_socket_connect(workload, driver, endpoint); + } + + @Override + public long socketConnect(long workload, long driver, long endpoint) { + return socketConnect0(workload, driver, endpoint); + } + + private static long socketAccept0(long workload, long driver, long listener) { + return BemoNative.elide_transport_socket_accept(workload, driver, listener); + } + + @Override + public long socketAccept(long workload, long driver, long listener) { + return socketAccept0(workload, driver, listener); + } + + private static int socketAdopt0(long workload, long driver, long socket) { + return BemoNative.elide_transport_socket_adopt(workload, driver, socket); + } + + @Override + public int socketAdopt(long workload, long driver, long socket) { + return socketAdopt0(workload, driver, socket); + } + + private static int socketDiscard0(long socket) { + return BemoNative.elide_transport_socket_discard(socket); + } + + @Override + public int socketDiscard(long socket) { + return socketDiscard0(socket); + } + + private static int socketAddress0(long driver, long socket, int peer, long output) { + return BemoNative.elide_transport_socket_address(driver, socket, peer, output); + } + + @Override + public int socketAddress(long driver, long socket, int peer, long output) { + return socketAddress0(driver, socket, peer, output); + } + + private static long socketReceive0(long workload, long driver, long socket, long buffer) { + return BemoNative.elide_transport_socket_receive(workload, driver, socket, buffer); + } + + @Override + public long socketReceive(long workload, long driver, long socket, long buffer) { + return socketReceive0(workload, driver, socket, buffer); + } + + private static long socketReceiveNew0( + long workload, long driver, long socket, long owner, long capacity) { + return BemoNative.elide_transport_socket_receive_new(workload, driver, socket, owner, capacity); + } + + @Override + public long socketReceiveNew(long workload, long driver, long socket, long owner, long capacity) { + return socketReceiveNew0(workload, driver, socket, owner, capacity); + } + + private static long socketSend0( + long workload, long driver, long socket, long buffer, long offset, long length) { + return BemoNative.elide_transport_socket_send(workload, driver, socket, buffer, offset, length); + } + + @Override + public long socketSend( + long workload, long driver, long socket, long buffer, long offset, long length) { + return socketSend0(workload, driver, socket, buffer, offset, length); + } + + private static int socketClose0(long driver, long socket) { + return BemoNative.elide_transport_socket_close(driver, socket); + } + + @Override + public int socketClose(long driver, long socket) { + return socketClose0(driver, socket); + } + + private static int driverPoll0(long driver, long timeoutNanos, long batch, int maximum) { + return BemoNative.elide_transport_driver_poll(driver, timeoutNanos, batch, maximum); + } + + @Override + public int driverPoll(long driver, long timeoutNanos, long batch, int maximum) { + return driverPoll0(driver, timeoutNanos, batch, maximum); + } + + private static int socketHttp0( + long workload, long driver, long socket, long owner, long capacity) { + return BemoNative.elide_transport_socket_http(workload, driver, socket, owner, capacity); + } + + @Override + public int socketHttp(long workload, long driver, long socket, long owner, long capacity) { + return socketHttp0(workload, driver, socket, owner, capacity); + } + + private static int socketHttpTls0( + long workload, long driver, long socket, long owner, long capacity, long context) { + return BemoNative.elide_transport_socket_http_tls( + workload, driver, socket, owner, capacity, context); + } + + @Override + public int socketHttpTls( + long workload, long driver, long socket, long owner, long capacity, long context) { + return socketHttpTls0(workload, driver, socket, owner, capacity, context); + } + + private static int httpMethod0(long exchange) { + return BemoNative.elide_transport_http_method(exchange); + } + + @Override + public int httpMethod(long exchange) { + return httpMethod0(exchange); + } + + private static int httpVersion0(long exchange) { + return BemoNative.elide_transport_http_version(exchange); + } + + @Override + public int httpVersion(long exchange) { + return httpVersion0(exchange); + } + + private static int httpHeaderCount0(long exchange) { + return BemoNative.elide_transport_http_header_count(exchange); + } + + @Override + public int httpHeaderCount(long exchange) { + return httpHeaderCount0(exchange); + } + + private static int httpKeepAlive0(long exchange) { + return BemoNative.elide_transport_http_keep_alive(exchange); + } + + @Override + public int httpKeepAlive(long exchange) { + return httpKeepAlive0(exchange); + } + + private static int httpView0(long exchange, int kind, int index, long output) { + return BemoNative.elide_transport_http_view(exchange, kind, index, output); + } + + @Override + public int httpView(long exchange, int kind, int index, long output) { + return httpView0(exchange, kind, index, output); + } + + private static int httpRespond0( + long driver, + long exchange, + int status, + long headers, + int count, + long body, + long bodyLength, + int flags) { + return BemoNative.elide_transport_http_respond( + driver, exchange, status, headers, count, body, bodyLength, flags); + } + + @Override + public int httpRespond( + long driver, + long exchange, + int status, + long headers, + int count, + long body, + long bodyLength, + int flags) { + return httpRespond0(driver, exchange, status, headers, count, body, bodyLength, flags); + } + + private static int httpSpans0(long exchange, long output, int capacity) { + return BemoNative.elide_transport_http_spans(exchange, output, capacity); + } + + @Override + public int httpSpans(long exchange, long output, int capacity) { + return httpSpans0(exchange, output, capacity); + } + + private static int httpRelease0(long driver, long exchange) { + return BemoNative.elide_transport_http_release(driver, exchange); + } + + @Override + public int httpRelease(long driver, long exchange) { + return httpRelease0(driver, exchange); + } + + private static long httpRetain0(long exchange, long lengthOut) { + return BemoNative.elide_transport_http_retain(exchange, lengthOut); + } + + @Override + public long httpRetain(long exchange, long lengthOut) { + return httpRetain0(exchange, lengthOut); + } + + private static int httpRetire0(long driver) { + return BemoNative.elide_transport_http_retire(driver); + } + + @Override + public int httpRetire(long driver) { + return httpRetire0(driver); + } + + private static int httpSegmentReleaseRetired0(long segment) { + return BemoNative.elide_transport_http_segment_release_retired(segment); + } + + @Override + public int httpSegmentReleaseRetired(long segment) { + return httpSegmentReleaseRetired0(segment); + } + + private static int httpDrain0(long driver) { + return BemoNative.elide_transport_http_drain(driver); + } + + @Override + public int httpDrain(long driver) { + return httpDrain0(driver); + } + + private static int httpFree0(long driver, long exchange) { + return BemoNative.elide_transport_http_free(driver, exchange); + } + + @Override + public int httpFree(long driver, long exchange) { + return httpFree0(driver, exchange); + } + + private static int httpHeadRelease0(long head) { + return BemoNative.elide_transport_http_head_release(head); + } + + @Override + public int httpHeadRelease(long head) { + return httpHeadRelease0(head); + } + + private static long httpPrepare0(long exchange, long capacity) { + return BemoNative.elide_transport_http_prepare(exchange, capacity); + } + + @Override + public long httpPrepare(long exchange, long capacity) { + return httpPrepare0(exchange, capacity); + } + + private static int httpSend0(long driver, long exchange, long length, int flags) { + return BemoNative.elide_transport_http_send(driver, exchange, length, flags); + } + + @Override + public int httpSend(long driver, long exchange, long length, int flags) { + return httpSend0(driver, exchange, length, flags); + } + + private static int httpSegmentAck0(long driver, long segment) { + return BemoNative.elide_transport_http_segment_ack(driver, segment); + } + + @Override + public int httpSegmentAck(long driver, long segment) { + return httpSegmentAck0(driver, segment); + } + + private static int httpSegmentRelease0(long driver, long segment) { + return BemoNative.elide_transport_http_segment_release(driver, segment); + } + + @Override + public int httpSegmentRelease(long driver, long segment) { + return httpSegmentRelease0(driver, segment); + } + + private static long httpChunkPrepare0(long exchange, long capacity, long addressOut) { + return BemoNative.elide_transport_http_chunk_prepare(exchange, capacity, addressOut); + } + + @Override + public long httpChunkPrepare(long exchange, long capacity, long addressOut) { + return httpChunkPrepare0(exchange, capacity, addressOut); + } + + private static int httpChunkSend0( + long driver, long exchange, long buffer, long length, int flags) { + return BemoNative.elide_transport_http_chunk_send(driver, exchange, buffer, length, flags); + } + + @Override + public int httpChunkSend(long driver, long exchange, long buffer, long length, int flags) { + // CHUNK_RETAIN ownership is enforced by the shared ABI; forward flags unchanged. + return httpChunkSend0(driver, exchange, buffer, length, flags); + } + + private static int socketOption0(long driver, long socket, int option, int value) { + return BemoNative.elide_transport_socket_option(driver, socket, option, value); + } + + @Override + public int socketOption(long driver, long socket, int option, int value) { + return socketOption0(driver, socket, option, value); + } + + private static int socketShutdown0(long driver, long socket, int direction) { + return BemoNative.elide_transport_socket_shutdown(driver, socket, direction); + } + + @Override + public int socketShutdown(long driver, long socket, int direction) { + return socketShutdown0(driver, socket, direction); + } + + private static long tlsClient0(long workload, long roots, long alpn) { + return BemoNative.elide_transport_tls_client(workload, roots, alpn); + } + + @Override + public long tlsClient(long workload, long roots, long alpn) { + return tlsClient0(workload, roots, alpn); + } + + private static long tlsServer0(long workload, long chain, long key, long alpn) { + return BemoNative.elide_transport_tls_server(workload, chain, key, alpn); + } + + @Override + public long tlsServer(long workload, long chain, long key, long alpn) { + return tlsServer0(workload, chain, key, alpn); + } + + private static int tlsContextRelease0(long context) { + return BemoNative.elide_transport_tls_context_release(context); + } + + @Override + public int tlsContextRelease(long context) { + return tlsContextRelease0(context); + } + + private static long tlsNew0(long workload, long context, long owner, long name) { + return BemoNative.elide_transport_tls_new(workload, context, owner, name); + } + + @Override + public long tlsNew(long workload, long context, long owner, long name) { + return tlsNew0(workload, context, owner, name); + } + + private static int tlsFeed0(long session, long input, long length) { + return BemoNative.elide_transport_tls_feed(session, input, length); + } + + @Override + public int tlsFeed(long session, long input, long length) { + return tlsFeed0(session, input, length); + } + + private static int tlsStep0( + long session, int action, long plaintext, long offset, long length, long output) { + return BemoNative.elide_transport_tls_step(session, action, plaintext, offset, length, output); + } + + @Override + public int tlsStep( + long session, int action, long plaintext, long offset, long length, long output) { + return tlsStep0(session, action, plaintext, offset, length, output); + } + + private static int tlsProtocol0(long session, long output) { + return BemoNative.elide_transport_tls_protocol(session, output); + } + + @Override + public int tlsProtocol(long session, long output) { + return tlsProtocol0(session, output); + } + + private static int tlsRelease0(long session) { + return BemoNative.elide_transport_tls_release(session); + } + + @Override + public int tlsRelease(long session) { + return tlsRelease0(session); + } + + private static long engineContextNew0( + long workload, + int flags, + long certificates, + long certificatesLength, + long key, + long keyLength, + long alpn, + long alpnLength) { + return BemoNative.elide_transport_engine_context_new( + workload, flags, certificates, certificatesLength, key, keyLength, alpn, alpnLength); + } + + private static int engineContextRelease0(long context) { + return BemoNative.elide_transport_engine_context_release(context); + } + + private static long engineNew0(long workload, long context, long name, long nameLength) { + return BemoNative.elide_transport_engine_new(workload, context, name, nameLength); + } + + private static long engineWrap0( + long engine, long source, long sourceLength, long destination, long destinationLength) { + return BemoNative.elide_transport_engine_wrap( + engine, source, sourceLength, destination, destinationLength); + } + + private static long engineUnwrap0( + long engine, + long source, + long sourceLength, + long destination, + long destinationLength, + int flags) { + return BemoNative.elide_transport_engine_unwrap( + engine, source, sourceLength, destination, destinationLength, flags); + } + + private static long engineControl0(long engine, int operation) { + return BemoNative.elide_transport_engine_control(engine, operation); + } + + private static int engineInfo0(long engine, int kind, int index, long output, long capacity) { + return BemoNative.elide_transport_engine_info(engine, kind, index, output, capacity); + } + + private static int engineRelease0(long engine) { + return BemoNative.elide_transport_engine_release(engine); + } + + @Override + public int engineContextRelease(long context) { + return engineContextRelease0(context); + } + + @Override + public long engineControl(long engine, int operation) { + return engineControl0(engine, operation); + } + + @Override + public int engineRelease(long engine) { + return engineRelease0(engine); + } + + @Override + public ByteBuffer bufferView(long buffer) { + return BemoNative.bufferView(buffer); + } + + @Override + public int bufferCapacity(long buffer) { + return BemoNative.bufferCapacity(buffer); + } + + @Override + public long bufferAddress(long buffer) { + return BemoNative.bufferAddress(buffer); + } + + @Override + public ByteBuffer memory(long address, int length) { + return BemoNative.directBuffer(address, length); + } + + @Override + public long getLong(long address) { + return BemoNative.getLong(address); + } + + @Override + public int getInt(long address) { + return BemoNative.getInt(address); + } + + @Override + public byte getByte(long address) { + return BemoNative.getByte(address); + } + + @Override + public void copyBytes(long address, byte[] target, int offset, int length) { + BemoNative.copyBytes(address, target, offset, length); + } + + @Override + public void writeBytes(long address, byte[] source, int offset, int length) { + BemoNative.writeBytes(address, source, offset, length); + } + + @Override + public byte[] bytes(long address, int length) { + byte[] result = new byte[length]; + BemoNative.copyBytes(address, result, 0, length); + return result; + } +} diff --git a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNativeProvider.java b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNativeProvider.java new file mode 100644 index 0000000..66e4c3b --- /dev/null +++ b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNativeProvider.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024-2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.transport.bali; + +import bali.api.BemoNative; +import dev.elide.bemo.transport.TransportNative; +import dev.elide.bemo.transport.TransportNativeProvider; +import org.jspecify.annotations.Nullable; + +/** Direct Bali backend takes priority; an absent embedding remains a quiet portable fallback. */ +public final class BaliTransportNativeProvider implements TransportNativeProvider { + public BaliTransportNativeProvider() {} + + @Override + public int priority() { + return 100; + } + + @Override + public @Nullable TransportNative open() { + try { + if (!BemoNative.available()) return null; + } catch (NoClassDefFoundError absent) { + return null; + } + long versions = BemoNative.abiVersion(); + if ((versions >>> 32) != dev.elide.bemo.TransportNative.ABI_VERSION + || (versions & 0xffffffffL) != TransportNative.ABI_VERSION) { + throw new LinkageError("Bemo/Bali ABI mismatch: " + Long.toUnsignedString(versions)); + } + return new BaliTransportNative(); + } +} diff --git a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/package-info.java b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/package-info.java new file mode 100644 index 0000000..903aab8 --- /dev/null +++ b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/package-info.java @@ -0,0 +1,3 @@ +/** Direct Bali guest bindings, without GraalVM or Netty class identity dependencies. */ +@org.jspecify.annotations.NullMarked +package dev.elide.bemo.transport.bali; diff --git a/packages/bali/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider b/packages/bali/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider new file mode 100644 index 0000000..7f34b6f --- /dev/null +++ b/packages/bali/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider @@ -0,0 +1 @@ +dev.elide.bemo.transport.bali.BaliTransportNativeProvider diff --git a/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNativeProvider.java b/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNativeProvider.java new file mode 100644 index 0000000..4273be6 --- /dev/null +++ b/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNativeProvider.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024-2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.transport; + +/** Portable fallback after direct embedding providers have declined or refused the ABI. */ +public final class FfmTransportNativeProvider implements TransportNativeProvider { + /** ServiceLoader constructor; native initialization happens only when selected. */ + public FfmTransportNativeProvider() {} + + @Override + public int priority() { + return 0; + } + + @Override + public TransportNative open() { + try (var metadata = new dev.elide.bemo.ffm.FfmTransportNative()) { + metadata.requireCompatible(); + } + try { + return new FfmTransportNative(); + } catch (IllegalArgumentException incompatible) { + throw new LinkageError(incompatible.getMessage(), incompatible); + } + } +} diff --git a/packages/ffm/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider b/packages/ffm/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider new file mode 100644 index 0000000..7249873 --- /dev/null +++ b/packages/ffm/src/main/resources/META-INF/services/dev.elide.bemo.transport.TransportNativeProvider @@ -0,0 +1 @@ +dev.elide.bemo.transport.FfmTransportNativeProvider diff --git a/tests/java/dev/elide/bemo/FfmContract.java b/tests/java/dev/elide/bemo/FfmContract.java index 855d7ee..23494a6 100644 --- a/tests/java/dev/elide/bemo/FfmContract.java +++ b/tests/java/dev/elide/bemo/FfmContract.java @@ -39,6 +39,32 @@ public static void main(String[] args) throws Exception { } } } + String previous = System.setProperty("bemo.native.path", args[0]); + try { + var selected = dev.elide.bemo.transport.TransportNative.load(); + if (!(selected instanceof dev.elide.bemo.transport.FfmTransportNative)) { + throw new AssertionError("ServiceLoader did not select the portable FFM backend"); + } + long owner = selected.ownerNew(4096); + if (owner == 0) throw new AssertionError("Selected backend rejected workload"); + try { + long buffer = selected.bufferNew(owner, 64); + if (buffer == 0) throw new AssertionError("Selected backend rejected buffer"); + try { + selected.bufferView(buffer).put((byte) 42); + if (selected.bufferFreeze(buffer, 1) != 0) throw new AssertionError("Freeze failed"); + if (selected.bufferView(buffer).get(0) != 42) + throw new AssertionError("Payload mismatch"); + } finally { + if (selected.bufferRelease(buffer) != 0) throw new AssertionError("Release failed"); + } + } finally { + if (selected.ownerRelease(owner) != 0) throw new AssertionError("Owner release failed"); + } + } finally { + if (previous == null) System.clearProperty("bemo.native.path"); + else System.setProperty("bemo.native.path", previous); + } System.out.println("FFM contract passed"); } } diff --git a/tests/java/dev/elide/bemo/transport/TransportNativeSelectionTest.java b/tests/java/dev/elide/bemo/transport/TransportNativeSelectionTest.java new file mode 100644 index 0000000..7b42813 --- /dev/null +++ b/tests/java/dev/elide/bemo/transport/TransportNativeSelectionTest.java @@ -0,0 +1,72 @@ +package dev.elide.bemo.transport; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** Configuration contract; no native library, framework, or GraalVM SDK is needed. */ +public final class TransportNativeSelectionTest { + private static TransportNative backend(int version) { + return (TransportNative) + Proxy.newProxyInstance( + TransportNative.class.getClassLoader(), + new Class[] {TransportNative.class}, + (proxy, method, arguments) -> { + if (method.getName().equals("version")) return version; + throw new AssertionError("Discovery must not invoke data-plane methods: " + method); + }); + } + + private static TransportNativeProvider provider(int priority, @Nullable TransportNative backend) { + return new TransportNativeProvider() { + @Override + public int priority() { + return priority; + } + + @Override + public @Nullable TransportNative open() { + return backend; + } + }; + } + + public static void main(String[] args) { + var direct = backend(3); + var fallback = backend(3); + var diagnostics = new ArrayList(); + assert TransportNativeDiscovery.choose( + List.of(provider(0, fallback), provider(100, direct)), diagnostics::add) + == direct; + assert diagnostics.isEmpty(); + assert TransportNativeDiscovery.choose( + List.of(provider(100, backend(99)), provider(0, fallback)), diagnostics::add) + == fallback; + assert diagnostics.size() == 1; + assert diagnostics.getFirst().contains("ABI mismatch"); + assert TransportNativeDiscovery.choose( + List.of(provider(100, null), provider(0, fallback)), diagnostics::add) + == fallback; + assert diagnostics.size() == 1; + var mismatchedMetadata = + new TransportNativeProvider() { + @Override + public int priority() { + return 200; + } + + @Override + public TransportNative open() { + throw new LinkageError("Bemo metadata ABI mismatch"); + } + }; + assert TransportNativeDiscovery.choose( + List.of(mismatchedMetadata, provider(0, fallback)), diagnostics::add) + == fallback; + assert diagnostics.size() == 2; + assert TransportNativeDiscovery.choose(List.of(), diagnostics::add) == null; + assert TransportNative.load() == null; // API-only classpath has no registered provider. + System.out.println("transport backend selection: OK"); + } +} diff --git a/tools/build.py b/tools/build.py index 8fe4564..0f3994d 100644 --- a/tools/build.py +++ b/tools/build.py @@ -23,7 +23,7 @@ VERSIONS = json.loads((ROOT / "tools/versions.json").read_text()) VERSION = (ROOT / ".version").read_text().strip() ELIDE = os.environ.get("ELIDE", "elide") -MODULES = ("api", "ffm", "native-image", "netty") +MODULES = ("api", "ffm", "native-image", "netty", "bali") MAVEN_GROUP = "dev.elide.bemo" MAVEN_PATH = MAVEN_GROUP.replace(".", "/") REPOSITORY = "https://github.com/elide-dev/bemo" @@ -118,6 +118,37 @@ def compile_java(output, inputs, dependencies=(), lint="all"): "-cp", classpath([*dependencies, jspecify()]), "-d", output, *inputs) +def bali_api_classpath(): + """Compile-only contract; never package a duplicate of the image-owned guest API.""" + provided = os.environ.get("BALI_API_CLASSPATH") + if provided: + return [Path(value) for value in provided.split(os.pathsep)] + destination = BUILD / "bali-api-stubs" + source = BUILD / "bali-api-contract/bali/api/BemoNative.java" + source.parent.mkdir(parents=True, exist_ok=True) + methods = ["package bali.api;", "public final class BemoNative {", + "private BemoNative() {}", "public static boolean available() { return false; }"] + for name, (returns, params) in seam.descriptor_functions(seam.DESCRIPTOR.read_text()).items(): + if "callback" in name: continue + carrier = "int" if returns in ("i32", "u32") else "long" + arguments = ", ".join(("int" if typ in ("i32", "u32") else "long") + " " + param + for param, typ in params) + methods.append(f"public static {carrier} {name}({arguments}) {{ throw new UnsupportedOperationException(); }}") + helpers = [("long", "abiVersion", ""), ("java.nio.ByteBuffer", "bufferView", "long buffer"), + ("java.nio.ByteBuffer", "directBuffer", "long address, int length"), + ("long", "bufferAddress", "long buffer"), ("int", "bufferCapacity", "long buffer"), + ("long", "getLong", "long address"), ("int", "getInt", "long address"), + ("byte", "getByte", "long address"), + ("void", "copyBytes", "long address, byte[] target, int offset, int length"), + ("void", "writeBytes", "long address, byte[] source, int offset, int length")] + for returns, name, params in helpers: + methods.append(f"public static {returns} {name}({params}) {{ throw new UnsupportedOperationException(); }}") + methods.append("}") + source.write_text("\n".join(methods) + "\n") + compile_java(destination, [source], []) + return [destination] + + def jvm(): deps() seam.generate(check=True) @@ -127,6 +158,8 @@ def jvm(): cp += sdk() if module == "netty": cp += netty() + if module == "bali": + cp += bali_api_classpath() compile_java(classes(module), sources(module), cp) resources = ROOT / "packages" / module / "src/main/resources" if resources.is_dir(): @@ -175,8 +208,14 @@ def ffm_contracts(reports, native, agent=(), env=None, cc=None): cc = list(cc or [os.environ.get("CC", "cc")]) test_root = ROOT / "tests/java/dev/elide/bemo" output = BUILD / "tests/ffm" - cp = [classes("api"), classes("ffm")] + cp = [classes("api"), classes("ffm"), classes("bali")] compile_java(output, [test_root / "Contract.java", test_root / "FfmContract.java"], cp) + selection = BUILD / "tests/selection" + compile_java(selection, [test_root / "transport/TransportNativeSelectionTest.java"], + [classes("api"), jspecify()]) + reports.run("TransportNativeSelectionTest", [java_tool("java"), "-ea", "-cp", + classpath([selection, classes("api"), jspecify()]), + "dev.elide.bemo.transport.TransportNativeSelectionTest"], timeout=30, cwd=ROOT) extra = [] if os.name != "nt": incompatible = BUILD / "tests" / native.name.replace("bemo_ffi", "incompatible") @@ -438,6 +477,8 @@ def package(): cp += sdk() if module == "netty": cp += netty() + if module == "bali": + cp += bali_api_classpath() # Javadoc is run by Elide's JVM toolchain, not a second build system. run(ELIDE, "java", "--", "-m", "jdk.javadoc/jdk.javadoc.internal.tool.Main", "-quiet", "-notimestamp", "-Werror", "-Xdoclint:all,-missing", "--release", VERSIONS["jvm_release"], "-d", docs, diff --git a/tools/generate_exports.py b/tools/generate_exports.py index 1777db1..ed770fb 100644 --- a/tools/generate_exports.py +++ b/tools/generate_exports.py @@ -29,6 +29,7 @@ def generate(): #![allow(clippy::too_many_arguments)] use bemo::abi::{BufferView, NativeEvent, ReceiveResult}; use std::ffi::c_void; +use crate::guard::BOUNDARY; '''] names = set() base = ROOT / "crates/bemo/src" @@ -51,10 +52,16 @@ def generate(): if unsafe: output.append(f"///\n/// # Safety\n/// The caller must satisfy the safety contract of [`{target}`].\n") output.append(f'#[unsafe(no_mangle)]\npub {unsafe or ""}extern "C" fn {name}({params}) {returns} {{\n') + if not args and not unsafe: + output.append(f" BOUNDARY.call({target})\n") + output.append("}\n\n") + continue + output.append(" BOUNDARY.call(|| {\n") if unsafe: - output.append(f" // SAFETY: the exported contract is identical to the Rust handle API.\n unsafe {{ {target}({args}) }}\n") + output.append(f" // SAFETY: the exported contract is identical to the Rust handle API.\n unsafe {{ {target}({args}) }}\n") else: - output.append(f" {target}({args})\n") + output.append(f" {target}({args})\n") + output.append(" })\n") output.append("}\n\n") header = (ROOT / "include/elide_transport.h").read_text() declarations = set(re.findall(r'^\w+[\w\s*]*\b(elide_transport_\w+)\(', header, re.M)) From 86b4a32385f352e98b6863bfa0c675d6af1e3898 Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Wed, 14 Oct 2026 11:57:48 -0700 Subject: [PATCH 2/4] feat(http): report physical completion for complete HTTP/1 responses --- crates/bemo-ffi/src/lib.rs | 2 +- crates/bemo/src/abi/http.rs | 50 +++++++++++++++--- crates/bemo/src/lib.rs | 4 +- crates/bemo/tests/http.rs | 51 +++++++++++++++++++ include/bemo.h | 1 + include/elide_transport.h | 7 ++- .../elide/bemo/transport/TransportNative.java | 11 ++++ .../transport/bali/BaliTransportNative.java | 8 +++ .../bemo/transport/FfmTransportNative.java | 21 ++++++++ .../transport/svm/CapiTransportNative.java | 7 +++ tests/abi.c | 2 +- tests/java/dev/elide/bemo/Contract.java | 2 +- 12 files changed, 153 insertions(+), 13 deletions(-) diff --git a/crates/bemo-ffi/src/lib.rs b/crates/bemo-ffi/src/lib.rs index e6ac49c..7471d58 100644 --- a/crates/bemo-ffi/src/lib.rs +++ b/crates/bemo-ffi/src/lib.rs @@ -50,7 +50,7 @@ mod tests { #[test] fn boundary_matches_core() { assert_eq!(bemo_abi_version(), bemo::ABI_VERSION); - assert_eq!(bemo_capabilities(), bemo::CAP_TRANSPORT_V3 | bemo::CAP_NATIVE_ALLOC); + assert_eq!(bemo_capabilities(), bemo::CAPABILITIES); } } diff --git a/crates/bemo/src/abi/http.rs b/crates/bemo/src/abi/http.rs index f5e5384..0a300bf 100644 --- a/crates/bemo/src/abi/http.rs +++ b/crates/bemo/src/abi/http.rs @@ -85,6 +85,8 @@ pub const HAS_BODY: u8 = 1; pub const HAS_HEADER_METADATA: u8 = 2; /// Flag for [`elide_transport_http_respond`]: send the head only; the body follows as parts. pub const RESPOND_STREAM: u32 = 2; +/// Report one physical completion for a complete HTTP/1 response. +pub const RESPOND_COMPLETE_CREDIT: u32 = 4; /// Flag for [`elide_transport_http_chunk_send`]: this part ends the response. pub const CHUNK_FINAL: u32 = 2; /// Retain a frozen body handle instead of consuming prepared mutable storage. HTTP/1 chunked @@ -320,6 +322,8 @@ struct Parts { queued_bytes: usize, /// Parts arrive through `chunk_send` and each reports [`EVENT_PART_SENT`]. streaming: bool, + /// Report physical completion for each queued part. + report: bool, framing: Framing, /// Payload bytes accepted so far, checked against `Framing::Length`. body_bytes: u64, @@ -336,6 +340,7 @@ impl Parts { complete: true, queued_bytes: len, streaming: false, + report: false, framing: Framing::Length(len as u64), body_bytes: 0, bodiless: false, @@ -1196,7 +1201,7 @@ fn pump(state: &mut DriverState, socket: u64, events: &mut VecDeque exchange: front, len, remaining: len, - report: parts.streaming, + report: parts.report, }); if let Some(previous) = batch.last_mut() { if let Err(part) = previous.merge(part) { @@ -1315,7 +1320,7 @@ fn fail_writes(http: &mut HttpSocket, socket: u64, code: i64, events: &mut VecDe } } for (exchange, parts) in http.ready.drain() { - if parts.streaming { + if parts.report { for _ in 0..parts.len() { events.push_back(event(EVENT_PART_SENT, 0, socket, exchange, code)); } @@ -1517,6 +1522,8 @@ pub unsafe fn elide_transport_http_spans(exchange: u64, output: *mut u32, capaci /// `body_length` declares the body (`content-length`), or `u64::MAX` for an unknown length /// (`transfer-encoding: chunked`; on HTTP/1.0 the connection closes after the body instead). /// Parts then follow through [`elide_transport_http_chunk_send`]. +/// [`RESPOND_COMPLETE_CREDIT`] instead requests one physical completion for a complete +/// HTTP/1 response; it cannot be combined with streaming or used on HTTP/2. /// /// # Safety /// Every address must be readable for its stated length for the duration of the call. @@ -1535,6 +1542,10 @@ pub unsafe fn elide_transport_http_respond( return INVALID; } let stream = flags & RESPOND_STREAM != 0; + let complete_credit = flags & RESPOND_COMPLETE_CREDIT != 0; + if stream && complete_credit { + return INVALID; + } let framing = match body_length { u64::MAX if stream => Framing::Chunked, declared => Framing::Length(declared), @@ -1566,6 +1577,9 @@ pub unsafe fn elide_transport_http_respond( .get(&exchange) .is_some_and(|entry| entry.stream != 0) { + if complete_credit { + return INVALID; + } return h2_socket::respond(state, exchange, status as u16, &headers, body, body_length, stream); } let Some(entry) = state.http.exchanges.get_mut(&exchange) else { @@ -1623,9 +1637,11 @@ pub unsafe fn elide_transport_http_respond( }; entry.responded = true; let mut parts = Parts::single(encoded); + parts.report = complete_credit; if stream { parts.complete = false; parts.streaming = true; + parts.report = true; parts.framing = framing; parts.bodiless = bodiless; } @@ -1985,12 +2001,13 @@ pub fn elide_transport_http_free(driver: u64, exchange: u64) -> i32 { } if let Some(http) = state.http.sockets.get_mut(&socket) { let queued = if let Some(parts) = http.ready.get_mut(&exchange) { - // Buffered parts never report, and queue ownership already proves quarantine is needed. - if parts.streaming { + // Suppress credited parts; queue ownership still requires quarantine until retirement. + if parts.report { for part in http.inflight.iter_mut().filter(|p| p.exchange == exchange) { part.report = false; } parts.streaming = false; + parts.report = false; } if !parts.complete { parts.complete = true; @@ -2159,6 +2176,24 @@ mod tests { assert_eq!(budget.used(), 0); } + #[test] + fn complete_response_failure_reports_once_and_releases_storage() { + let budget = Budget::new(1024); + let mut bytes = Buffer::new(8, budget.clone()).unwrap(); + bytes.write(0, b"response").unwrap(); + let mut parts = Parts::single(bytes.freeze()); + parts.report = true; + assert!(parts.complete); + assert!(!parts.streaming); + let mut socket = HttpSocket::new(budget.clone(), 1024, None); + socket.queue(123, parts); + let mut events = VecDeque::new(); + fail_writes(&mut socket, 456, -1, &mut events); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, EVENT_PART_SENT); + assert_eq!(budget.used(), 0); + } + #[test] fn response_parts_preserve_order_across_inline_spill_and_refill() { let budget = Budget::new(1024); @@ -2188,7 +2223,7 @@ mod tests { #[test] #[cfg_attr(miri, ignore = "native driver is unavailable under miri")] fn freed_stream_suppresses_reports_until_all_wire_references_retire() { - for queued in [false, true] { + for (queued, streaming) in [(false, false), (true, false), (false, true), (true, true)] { let owner = elide_transport_owner_new(1024 * 1024); let driver = elide_transport_driver_new(owner, Backend::Auto as u32, 8); let socket = identity(); @@ -2228,8 +2263,9 @@ mod tests { } if queued { let mut parts = Parts::single(byte()); - parts.streaming = true; - parts.complete = false; + parts.streaming = streaming; + parts.report = true; + parts.complete = !streaming; parts.queued_bytes += 2; http.queue(exchange, parts); } diff --git a/crates/bemo/src/lib.rs b/crates/bemo/src/lib.rs index b84702c..fcf19ee 100644 --- a/crates/bemo/src/lib.rs +++ b/crates/bemo/src/lib.rs @@ -21,5 +21,7 @@ pub const ABI_VERSION: u32 = 1; pub const CAP_TRANSPORT_V3: u64 = 1; /// `bemo_alloc`, `bemo_free`, and `bemo_alloc_live` allocate from the transport's allocator. pub const CAP_NATIVE_ALLOC: u64 = 2; +/// Complete HTTP/1 responses can report physical completion. +pub const CAP_HTTP1_COMPLETE_CREDIT: u64 = 4; /// Implemented data-plane capabilities. -pub const CAPABILITIES: u64 = CAP_TRANSPORT_V3 | CAP_NATIVE_ALLOC; +pub const CAPABILITIES: u64 = CAP_TRANSPORT_V3 | CAP_NATIVE_ALLOC | CAP_HTTP1_COMPLETE_CREDIT; diff --git a/crates/bemo/tests/http.rs b/crates/bemo/tests/http.rs index 49d9a54..a798417 100644 --- a/crates/bemo/tests/http.rs +++ b/crates/bemo/tests/http.rs @@ -1457,6 +1457,57 @@ fn op_limit_during_send_keeps_connection_alive() { assert_eq!(elide_transport_owner_release(owner), 0); } +#[test] +#[cfg_attr(miri, ignore = "native driver and sockets are unavailable under miri")] +fn complete_response_reports_one_physical_credit() { + let mut h = harness(); + h.peer.write_all(b"GET /fixed HTTP/1.1\r\nHost: a\r\n\r\n").unwrap(); + let exchange = h.take(EVENT_REQUEST, 1)[0].value; + assert_eq!( + // SAFETY: Live exchange and body, no header records. Invalid flags must not consume it. + unsafe { + elide_transport_http_respond( + h.driver, + exchange, + 200, + std::ptr::null(), + 0, + b"pong".as_ptr(), + 4, + RESPOND_STREAM | RESPOND_COMPLETE_CREDIT, + ) + }, + INVALID + ); + assert_eq!( + // SAFETY: Body remains readable until the synchronous encoder returns. + unsafe { + elide_transport_http_respond( + h.driver, + exchange, + 200, + std::ptr::null(), + 0, + b"pong".as_ptr(), + 4, + RESPOND_COMPLETE_CREDIT, + ) + }, + 0 + ); + let got = h.read_while_polling(|got| got.ends_with(b"pong")); + let parsed = responses(&got); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].1, b"pong"); + let sent = h.take(EVENT_PART_SENT, 1); + assert_eq!(sent[0].value, exchange); + assert_eq!(sent[0].result, got.len() as i64); + assert!(h.pending.iter().all(|event| event.kind != EVENT_PART_SENT)); + assert_eq!(elide_transport_http_free(h.driver, exchange), 0); + h.peer.shutdown(std::net::Shutdown::Both).unwrap(); + h.take(EVENT_CLOSED, 1); +} + #[test] #[cfg_attr(miri, ignore = "io_uring and real sockets are unavailable under miri")] fn streaming_response_is_chunked_byte_exact_and_holds_the_lane() { diff --git a/include/bemo.h b/include/bemo.h index bcefd33..fe1e89e 100644 --- a/include/bemo.h +++ b/include/bemo.h @@ -11,6 +11,7 @@ extern "C" { #define BEMO_ABI_VERSION 1 #define BEMO_CAP_TRANSPORT_V3 UINT64_C(1) #define BEMO_CAP_NATIVE_ALLOC UINT64_C(2) +#define BEMO_CAP_HTTP1_COMPLETE_CREDIT UINT64_C(4) /* Query version first. No allocation, callbacks, thread affinity, or ownership transfer. */ uint32_t bemo_abi_version(void); /* Implemented data-plane capabilities. Unknown bits must be ignored. */ diff --git a/include/elide_transport.h b/include/elide_transport.h index 34e19ac..fa18ade 100644 --- a/include/elide_transport.h +++ b/include/elide_transport.h @@ -168,8 +168,11 @@ int32_t elide_transport_http_header_count(uint64_t exchange); int32_t elide_transport_http_keep_alive(uint64_t exchange); /* kind: 0 method, 1 path, 2 header name, 3 header value, 4 body, 5 whole head; output receives address, length. */ int32_t elide_transport_http_view(uint64_t exchange, uint32_t kind, uint32_t index, uint64_t *output); -/* headers: count records of {name address, name length, value address, value length}. flags bit 1 - * (RESPOND_STREAM) sends the head only and ignores body: bodyLength then declares content-length, +/* headers: count records of {name address, name length, value address, value length}. + * Flag 4 (RESPOND_COMPLETE_CREDIT), advertised by Bemo capability 2, requests one + * PART_SENT after physical completion of a complete HTTP/1 response. It rejects + * streaming and HTTP/2 combinations. Freeing suppresses reports while retaining wire leases. + * Flag 2 (RESPOND_STREAM) sends the head only and ignores body: bodyLength then declares content-length, * or UINT64_MAX for chunked. Parts follow through http_chunk_send. */ int32_t elide_transport_http_respond(uint64_t driver, uint64_t exchange, uint32_t status, const uint64_t *headers, uint32_t count, const uint8_t *body, uint64_t bodyLength, uint32_t flags); /* u32 pairs within the head: method, path, then name/value per header; returns slots needed. */ diff --git a/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java index 514bcc9..b6ead05 100644 --- a/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java +++ b/packages/api/src/main/java/dev/elide/bemo/transport/TransportNative.java @@ -33,6 +33,14 @@ public interface TransportNative { /** Preserved transport ABI version, independent of Bemo metadata ABI 1. */ int ABI_VERSION = 3; + /** Optional metadata capability for one physical completion of a complete HTTP/1 response. */ + long CAP_HTTP1_COMPLETE_CREDIT = 4; + + /** Legacy backends keep the streaming response protocol. */ + default boolean supportsCompleteHttp1Responses() { + return false; + } + /** Return the preserved Elide transport ABI version; it must equal {@link #ABI_VERSION}. */ int version(); @@ -461,6 +469,9 @@ int socketHttpTls( /** Flag for {@link #httpRespond}: send the head only; the body follows as parts. */ int RESPOND_STREAM = 2; + /** Requires {@link #supportsCompleteHttp1Responses}; incompatible with streaming and HTTP/2. */ + int RESPOND_COMPLETE_CREDIT = 4; + /** * Respond to an exchange; its memory stays readable until {@link #httpFree}. {@code headers} * addresses {@code count} records of four longs (name address, name length, value address, value diff --git a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java index 0b707de..8bbc011 100644 --- a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java +++ b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java @@ -10,6 +10,14 @@ /** Direct image calls; the guest retains its own Bemo and framework classes. */ public final class BaliTransportNative implements TransportNative { + private final boolean completeHttp1 = + (BemoNative.bemo_capabilities() & CAP_HTTP1_COMPLETE_CREDIT) != 0; + + @Override + public boolean supportsCompleteHttp1Responses() { + return completeHttp1; + } + private static int servingAvailableCores0() { return BemoNative.elide_transport_serving_available_cores(); } diff --git a/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNative.java b/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNative.java index 145544c..0682cad 100644 --- a/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNative.java +++ b/packages/ffm/src/main/java/dev/elide/bemo/transport/FfmTransportNative.java @@ -29,6 +29,13 @@ public final class FfmTransportNative implements TransportNative { // no allocation, where a per-read `ofAddress(..).reinterpret(..)` or `asByteBuffer()` costs one. private static final MemorySegment ALL = MemorySegment.NULL.reinterpret(Long.MAX_VALUE); + private final boolean completeHttp1; + + @Override + public boolean supportsCompleteHttp1Responses() { + return completeHttp1; + } + private final MethodHandle version; private final MethodHandle lastError; private final MethodHandle ownerNew; @@ -272,6 +279,20 @@ public FfmTransportNative(Path library) { if (ValueLayout.ADDRESS.byteSize() != 8) throw new UnsupportedOperationException("64-bit JVM required"); SymbolLookup symbols = SymbolLookup.libraryLookup(library, Arena.global()); + boolean complete = false; + var capabilitySymbol = symbols.find("bemo_capabilities"); + if (capabilitySymbol.isPresent()) { + try { + MethodHandle capabilities = + Linker.nativeLinker() + .downcallHandle( + capabilitySymbol.get(), FunctionDescriptor.of(ValueLayout.JAVA_LONG)); + complete = ((long) capabilities.invokeExact() & CAP_HTTP1_COMPLETE_CREDIT) != 0; + } catch (Throwable error) { + throw failure(error); + } + } + completeHttp1 = complete; version = bind(symbols, "abi_version", ValueLayout.JAVA_INT); if (version() != TransportNative.ABI_VERSION) throw new IllegalArgumentException("Unsupported transport ABI version"); diff --git a/packages/native-image/src/main/java/dev/elide/bemo/transport/svm/CapiTransportNative.java b/packages/native-image/src/main/java/dev/elide/bemo/transport/svm/CapiTransportNative.java index b25b2ae..7e2b4b0 100644 --- a/packages/native-image/src/main/java/dev/elide/bemo/transport/svm/CapiTransportNative.java +++ b/packages/native-image/src/main/java/dev/elide/bemo/transport/svm/CapiTransportNative.java @@ -33,6 +33,13 @@ @CContext(CapiTransportNative.Headers.class) @CLibrary(value = "bemo_ffi", requireStatic = true) public final class CapiTransportNative implements TransportNative { + private final boolean completeHttp1 = + (BemoNatives.bemo_capabilities() & CAP_HTTP1_COMPLETE_CREDIT) != 0; + + @Override + public boolean supportsCompleteHttp1Responses() { + return completeHttp1; + } public static int servingHelperStart(long token) { return BemoNatives.elide_transport_serving_helper_start(token); diff --git a/tests/abi.c b/tests/abi.c index c4062f4..d7deb78 100644 --- a/tests/abi.c +++ b/tests/abi.c @@ -39,7 +39,7 @@ static void run(void *(*body)(void *)) { int main(void) { assert(bemo_abi_version() == BEMO_ABI_VERSION); - assert(bemo_capabilities() == (BEMO_CAP_TRANSPORT_V3 | BEMO_CAP_NATIVE_ALLOC)); + assert(bemo_capabilities() == (BEMO_CAP_TRANSPORT_V3 | BEMO_CAP_NATIVE_ALLOC | BEMO_CAP_HTTP1_COMPLETE_CREDIT)); uint64_t live = bemo_alloc_live(); run(allocate); assert(bemo_alloc_live() == live + (uint64_t)THREADS * BLOCKS * SIZE); diff --git a/tests/java/dev/elide/bemo/Contract.java b/tests/java/dev/elide/bemo/Contract.java index 4e9030c..6f95693 100644 --- a/tests/java/dev/elide/bemo/Contract.java +++ b/tests/java/dev/elide/bemo/Contract.java @@ -7,7 +7,7 @@ private Contract() {} public static void verify(TransportNative transport) { transport.requireCompatible(); if (transport.abiVersion() != 1 - || transport.capabilities() + || (transport.capabilities() & (TransportNative.CAP_TRANSPORT_V3 | TransportNative.CAP_NATIVE_ALLOC)) != (TransportNative.CAP_TRANSPORT_V3 | TransportNative.CAP_NATIVE_ALLOC)) { throw new AssertionError("Unexpected metadata ABI or transport capabilities"); } From adb3cdcffff441d595cab4e81ecd73c5369b71f8 Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Wed, 14 Oct 2026 11:58:05 -0700 Subject: [PATCH 3/4] feat(http): add native Ktor and Spring adapters with complete response delivery --- docs/http-frameworks.md | 90 ++ elide.pkl | 4 + .../bemo/transport/http/NativeHttpServer.java | 1199 +++++++++++++++++ .../main/kotlin/dev/elide/bemo/ktor/Bemo.kt | 335 +++++ .../bemo/spring/BemoHttpHandlerAdapter.java | 270 ++++ .../spring/BemoReactiveWebServerFactory.java | 159 +++ tests/transport/java/BackendTransport.java | 5 + tests/transport/java/CapiTlsChannelTest.java | 2 + .../transport/java/NativeHttpServerTest.java | 291 ++++ .../transport/java/NativeHttpWorkersTest.java | 432 ++++++ tools/build.py | 47 +- tools/versions.json | 5 +- 12 files changed, 2833 insertions(+), 6 deletions(-) create mode 100644 docs/http-frameworks.md create mode 100644 packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java create mode 100644 packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt create mode 100644 packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java create mode 100644 packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java create mode 100644 tests/transport/java/NativeHttpServerTest.java create mode 100644 tests/transport/java/NativeHttpWorkersTest.java diff --git a/docs/http-frameworks.md b/docs/http-frameworks.md new file mode 100644 index 0000000..80f46bd --- /dev/null +++ b/docs/http-frameworks.md @@ -0,0 +1,90 @@ +# Native HTTP framework adapters + +The Bali integration candidate adds a shared HTTP/1.1 loop and optional Ktor and +Spring WebFlux adapters. These modules are local candidates; release performance +and lifecycle qualification are still in progress. The Rust seam and generated +image imports remain unchanged. + +`NativeHttpServer` lives in `bemo-api`. Drivers, exchange handles and delivered +body segments stay on its thread. Request publishers copy a segment into managed +storage only when a subscriber requests it, then release the native segment and +its receive-window credit. Response publishers receive one item of demand at a +time; large items are split into native parts, and the next item is requested +after write completion. A response's `sent()` stage completes after transmission +or fails on disconnect, cancellation or server retirement. The server bounds +native storage, concurrent exchanges and total request-body bytes. The initial +implementation has one event loop per listener. + +`bemo-spring` supplies `BemoHttpHandlerAdapter` and +`BemoReactiveWebServerFactory`. A Boot application can register the factory as its +`ReactiveWebServerFactory` bean, leaving controllers, message readers/writers and +exception handlers unchanged. Select a compatible backend with +`TransportNative.load()` and retain the stock factory when it returns null. The +factory accepts ordinary Boot address/port and a single TLS server identity from +its SSL bundle. Custom protocol/cipher policy, client authentication and SNI +identities are explicitly refused until supported. HTTP/2, upgrades, compression +configuration and graceful drain are not qualified on this candidate. + +`bemo-ktor` supplies `dev.elide.bemo.ktor.Bemo`, an +`ApplicationEngineFactory`. Use `embeddedServer(Bemo)` with the same application +module. It uses Ktor's default engine pipeline, request transformations, routing, +suspending handlers and outgoing content implementations. Optional PEM certificate +and private-key bytes enable native TLS. An unavailable native backend is refused; +an application's engine selector must retain its stock engine as fallback. +Upgrades and HTTP/2 continue to require the stock engine. Standard Ktor SSL +connector configuration and graceful drain remain to be completed. + +All framework types remain in the application's class loader. `bemo-bali` selects +Bali's primitive image boundary; `bemo-ffm` supplies a stock-JVM backend. The image +API is provided at runtime and its compile-only stub is never packaged. Neither +native HTTP adapters nor the shared loop import Native Image types or Netty +transport internals. + +The shared loop contract covers empty and large bodies, fixed and chunked framing, +HEAD, malformed requests, verified TLS and full native-resource reclamation. +Bali's framework fixtures additionally prove the original controller/routing +responses with the direct backend selected. Throughput samples on an image built +with debug Rust archives are diagnostic only; acceptance requires optimized +archives/images and matched native stock-JVM controls. + +Request-head decoding copies the ABI 3 exchange layout, span table and complete head +into managed snapshots before invoking the application. Field strings decode from +those snapshots, preserving header order, duplicate and empty values, Latin-1 text, +the exact request target and HTTP version. This avoids a native view and byte-array +copy for each field; request objects retain no borrowed native memory. Serving +performance still requires a paired measurement. + +The HTTP server also accepts an opt-in worker count. Ktor exposes `nativeWorkers` +and Spring's factory exposes `setNativeWorkers`; both default to one. One listener +hands accepted sockets to workers round-robin using the portable adoption API. +Established connections, native drivers, request state and response publishers stay +on their owning worker. The native memory budget and maximum active exchanges are +shared across the server, rather than multiplied by its worker count. Startup +reports one bound port, including port zero; shutdown retires queued accepts and +all worker drivers before releasing the shared workload. A worker startup/runtime +failure stops the complete group. Multi-worker performance remains unqualified. + +Ktor's `dispatchCallsOnNativeWorker` option defaults to false. When enabled, a cached +coroutine dispatcher uses the request's `executor()` to queue application work on +its connection owner, including resumptions after suspension. Blocking application +code then occupies that transport worker. Executors reject submissions once shutdown +starts; already accepted callbacks drain before the worker's stopped stage completes. +Synthetic request objects without a native owner cannot supply an executor. This +option remains experimental until differential and serving checks qualify it. + +### Complete response transmission + +`NativeHttpServer.fixedBody(bytes)` identifies a known body whose bytes remain unchanged +until `Response.sent()` completes. Bodies up to the native response-part limit use one +HTTP/1 encode and one physical completion when metadata capability +`CAP_HTTP1_COMPLETE_CREDIT` is present. Legacy backends and larger or length-mismatched +bodies retain publisher streaming. Ktor byte responses and Spring single-buffer `Mono` +responses use this path; arbitrary publishers retain incremental demand. + +The optional transport flag `RESPOND_COMPLETE_CREDIT` is incompatible with streaming +and HTTP/2. Cancellation suppresses reports while retaining native wire leases until +retirement. ABI versions and existing flag behavior remain unchanged. + +Ktor cleanup checks whether a byte channel is already closed before invoking its +cancellation helper, avoiding an otherwise unused exception stack trace after success. +Open channels still receive cancellation. diff --git a/elide.pkl b/elide.pkl index cef0234..b636357 100644 --- a/elide.pkl +++ b/elide.pkl @@ -42,6 +42,10 @@ dependencies { "org.graalvm.sdk:nativeimage:\(versions.graalvm_sdk)" "org.graalvm.sdk:word:\(versions.graalvm_sdk)" "org.jspecify:jspecify:\(versions.jspecify)" + "org.springframework:spring-web:\(versions.spring)" + "org.springframework.boot:spring-boot-web-server:\(versions.spring_boot)" + "io.projectreactor:reactor-core:\(versions.reactor)" + "io.ktor:ktor-server-core-jvm:\(versions.ktor)" "com.google.errorprone:error_prone_core:\(versions.error_prone):with-dependencies" "com.uber.nullaway:nullaway:\(versions.nullaway)" "org.checkerframework:checker:\(versions.checker)" diff --git a/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java b/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java new file mode 100644 index 0000000..f54424c --- /dev/null +++ b/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java @@ -0,0 +1,1199 @@ +/* + * Copyright (c) 2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.transport.http; + +import dev.elide.bemo.transport.TransportNative; +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.Flow; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.Nullable; + +/** + * HTTP/1.1 event loops shared by framework adapters. Native driver operations stay on each worker. + * Request bodies retain native receive-window credit until requested; response publishers receive + * one item of demand at a time, after the previous part reaches the wire. No framework or image + * classes cross this API. + */ +public final class NativeHttpServer implements AutoCloseable { + public record Header(String name, String value) {} + + public record Request( + String method, + String target, + List
headers, + Flow.Publisher body, + InetSocketAddress localAddress, + InetSocketAddress remoteAddress, + boolean secure, + String version) { + /** Queue application work on this connection's owner; rejects after server shutdown. */ + public Executor executor() { + if (body instanceof Inbound inbound) return inbound.executor(); + throw new IllegalStateException("Request has no native worker"); + } + } + + /** A known body. The caller must not modify bytes until the response completes. */ + public static Flow.Publisher fixedBody(byte[] bytes) { + return new FixedBody(java.util.Objects.requireNonNull(bytes)); + } + + private record FixedBody(byte[] bytes) implements Flow.Publisher { + @Override + public void subscribe(Flow.Subscriber subscriber) { + subscriber.onSubscribe( + new Flow.Subscription() { + private boolean done; + private boolean cancelled; + + @Override + public void request(long n) { + if (done) return; + done = true; + if (n <= 0) { + subscriber.onError(new IllegalArgumentException("positive demand required")); + return; + } + if (bytes.length != 0) subscriber.onNext(bytes); + if (!cancelled) subscriber.onComplete(); + } + + @Override + public void cancel() { + cancelled = true; + done = true; + } + }); + } + } + + /** A response's completion represents physical transmission, or disconnect/cancellation. */ + public static final class Response { + public final int status; + public final List
headers; + public final long length; + public final Flow.Publisher body; + private final CompletableFuture sent = new CompletableFuture<>(); + + public Response(int status, List
headers, long length, Flow.Publisher body) { + if (status < 200 || status > 599 || length < -1) + throw new IllegalArgumentException("response"); + this.status = status; + this.headers = List.copyOf(headers); + this.length = length; + this.body = body; + } + + public CompletionStage sent() { + return sent.minimalCompletionStage(); + } + + /** Abort an application response after its headers have been selected. */ + public void cancel(Throwable failure) { + sent.completeExceptionally(failure); + } + } + + @FunctionalInterface + public interface Handler { + CompletionStage handle(Request request); + } + + /** One shared native workload; drivers and established connections remain worker-confined. */ + private static final class WorkerGroup implements AutoCloseable { + private final NativeHttpServer parent; + private final List workers = new ArrayList<>(); + private final Set pending = new HashSet<>(); + private final AtomicInteger remaining; + private final AtomicInteger exchanges = new AtomicInteger(); + private final AtomicReference failure = new AtomicReference<>(); + private volatile boolean closing; + private boolean started; + private int next; + private long owner; + + private static final class Accepted { + final AtomicLong socket; + + Accepted(long socket) { + this.socket = new AtomicLong(socket); + } + + void discard(TransportNative api) { + long handle = socket.getAndSet(0); + if (handle != 0) api.socketDiscard(handle); + } + } + + WorkerGroup(NativeHttpServer parent, int count) { + this.parent = parent; + remaining = new AtomicInteger(count); + for (int i = 0; i < count; i++) { + NativeHttpServer worker = + new NativeHttpServer( + parent.api, + parent.host, + parent.port, + parent.memoryLimit, + parent.bodyLimit, + parent.exchangeLimit, + parent.handler, + parent.certificate, + parent.privateKey, + 1, + this, + i); + workers.add(worker); + worker.stopped.whenComplete((ignored, error) -> workerStopped(error)); + } + } + + synchronized CompletionStage start() { + if (started || closing) return parent.bound; + started = true; + try { + owner = parent.api.ownerNew(parent.memoryLimit); + require(owner != 0, "shared owner admission"); + CompletableFuture.allOf( + workers.stream().map(worker -> worker.bound).toArray(CompletableFuture[]::new)) + .whenComplete( + (ignored, error) -> { + if (!closing) { + if (error != null) fail(error); + else parent.bound.complete(workers.get(0).bound.join()); + } + }); + for (NativeHttpServer worker : workers) worker.start(); + } catch (Throwable error) { + fail(error); + } + return parent.bound; + } + + private void fail(Throwable error) { + failure.compareAndSet(null, error); + parent.bound.completeExceptionally(error); + stopAll(); + } + + private void stopAll() { + List abandoned; + synchronized (this) { + if (closing) return; + closing = true; + abandoned = List.copyOf(pending); + pending.clear(); + } + for (Accepted socket : abandoned) { + try { + socket.discard(parent.api); + } catch (Throwable error) { + failure.compareAndSet(null, error); + } + } + parent.bound.completeExceptionally( + new java.util.concurrent.CancellationException("HTTP worker group stopped")); + for (NativeHttpServer worker : workers) { + try { + worker.stopLoop(); + } catch (Throwable error) { + failure.compareAndSet(null, error); + } + } + } + + private void workerStopped(@Nullable Throwable error) { + if (error != null) fail(error); + else if (!closing) fail(new IllegalStateException("HTTP worker stopped unexpectedly")); + if (remaining.decrementAndGet() != 0) return; + try { + require(exchanges.get() == 0, "shared exchange reclamation"); + if (owner != 0) { + require(parent.api.ownerUsed(owner) == 0, "shared native storage reclamation"); + require(parent.api.ownerRelease(owner) == 0, "shared owner retirement"); + } + } catch (Throwable cleanup) { + failure.compareAndSet(null, cleanup); + } + Throwable result = failure.get(); + if (result == null) parent.stopped.complete(null); + else parent.stopped.completeExceptionally(result); + } + + synchronized void accept(long socket) { + if (closing) { + parent.api.socketDiscard(socket); + return; + } + NativeHttpServer worker = workers.get(next); + next = (next + 1) % workers.size(); + Accepted lease = new Accepted(socket); + pending.add(lease); + if (!worker.submit( + () -> { + long handle = lease.socket.getAndSet(0); + synchronized (this) { + pending.remove(lease); + } + if (handle != 0) { + if (worker.running.get()) worker.adopt(handle); + else parent.api.socketDiscard(handle); + } + })) { + pending.remove(lease); + lease.discard(parent.api); + } + } + + boolean admitExchange() { + for (; ; ) { + int current = exchanges.get(); + if (current >= parent.exchangeLimit) return false; + if (exchanges.compareAndSet(current, current + 1)) return true; + } + } + + @Override + public void close() { + stopAll(); + for (NativeHttpServer worker : workers) worker.awaitLoop(); + } + } + + private static final int BATCH_SIZE = 256; + private static final int MAX_RESPONSE_PART = 64 * 1024; + private final @Nullable WorkerGroup workerGroup; + private final @Nullable WorkerGroup shared; + private final int workerIndex; + private final TransportNative api; + private final String host; + private final int port; + private final Handler handler; + private final long memoryLimit; + private final long bodyLimit; + private final int exchangeLimit; + private final byte @Nullable [] certificate; + private final byte @Nullable [] privateKey; + private final ConcurrentLinkedQueue tasks = new ConcurrentLinkedQueue<>(); + private final AtomicBoolean running = new AtomicBoolean(); + + private static final class ApplicationTask implements Runnable { + private final Runnable action; + + ApplicationTask(Runnable action) { + this.action = action; + } + + @Override + public void run() { + action.run(); + } + } + + private final Executor executor = + task -> { + if (!submit(new ApplicationTask(java.util.Objects.requireNonNull(task)))) + throw new RejectedExecutionException("Native HTTP worker stopped"); + }; + private boolean closed; + private final AtomicLong wakeDriver = new AtomicLong(); + private final CompletableFuture bound = new CompletableFuture<>(); + private final CompletableFuture stopped = new CompletableFuture<>(); + private final Map exchanges = new HashMap<>(); + private final Set sockets = new HashSet<>(); + + private record Peer(InetSocketAddress local, InetSocketAddress remote) {} + + private final Map peers = new HashMap<>(); + private long owner; + private long driver; + private long listener; + private long tls; + private long batch; + private long descriptorBuffer; + private long descriptor; + private @Nullable Thread thread; + + public NativeHttpServer( + TransportNative api, + String host, + int port, + long memoryLimit, + long bodyLimit, + int exchangeLimit, + Handler handler) { + this(api, host, port, memoryLimit, bodyLimit, exchangeLimit, handler, null, null); + } + + public NativeHttpServer( + TransportNative api, + String host, + int port, + long memoryLimit, + long bodyLimit, + int exchangeLimit, + Handler handler, + byte @Nullable [] certificate, + byte @Nullable [] privateKey) { + this( + api, + host, + port, + memoryLimit, + bodyLimit, + exchangeLimit, + handler, + certificate, + privateKey, + 1); + } + + /** Workers share admission limits; accepted connections stay on their selected worker. */ + public NativeHttpServer( + TransportNative api, + String host, + int port, + long memoryLimit, + long bodyLimit, + int exchangeLimit, + Handler handler, + byte @Nullable [] certificate, + byte @Nullable [] privateKey, + int workers) { + this( + api, + host, + port, + memoryLimit, + bodyLimit, + exchangeLimit, + handler, + certificate, + privateKey, + workers, + null, + 0); + } + + private NativeHttpServer( + TransportNative api, + String host, + int port, + long memoryLimit, + long bodyLimit, + int exchangeLimit, + Handler handler, + byte @Nullable [] certificate, + byte @Nullable [] privateKey, + int workers, + @Nullable WorkerGroup shared, + int workerIndex) { + if (port < 0 + || port > 65535 + || memoryLimit < 16384 + || bodyLimit < 0 + || exchangeLimit < 1 + || workers < 1 + || (certificate == null) != (privateKey == null)) + throw new IllegalArgumentException("server limits"); + this.shared = shared; + this.workerIndex = workerIndex; + this.api = api; + this.host = host; + this.port = port; + this.memoryLimit = memoryLimit; + this.bodyLimit = bodyLimit; + this.exchangeLimit = exchangeLimit; + this.handler = handler; + this.certificate = certificate == null ? null : certificate.clone(); + this.privateKey = privateKey == null ? null : privateKey.clone(); + this.workerGroup = workers == 1 ? null : new WorkerGroup(this, workers); + } + + /** Start once; the result contains the actual bound port. */ + public synchronized CompletionStage start() { + if (closed || stopped.isDone()) + return CompletableFuture.failedFuture(new IllegalStateException("HTTP server closed")); + if (workerGroup != null) return workerGroup.start(); + if (thread == null) { + running.set(true); + thread = new Thread(this::run, shared == null ? "bemo-http" : "bemo-http-" + workerIndex); + try { + thread.start(); + } catch (RuntimeException | Error failure) { + running.set(false); + bound.completeExceptionally(failure); + stopped.completeExceptionally(failure); + throw failure; + } + } + return bound; + } + + public CompletionStage stopped() { + return stopped; + } + + private boolean submit(Runnable task) { + if (!running.get()) return false; + tasks.add(task); + if (!running.get() && tasks.remove(task)) return false; + long handle = wakeDriver.get(); + if (handle != 0 && Thread.currentThread() != thread) api.driverWake(handle); + return true; + } + + private static void require(boolean ok, String operation) { + if (!ok) throw new IllegalStateException("Bemo HTTP: " + operation); + } + + private long allocate(int capacity) { + long handle = api.bufferNew(owner, Math.max(1, capacity)); + require(handle != 0, "native storage admission"); + return handle; + } + + private long upload(byte[] bytes) { + long handle = allocate(bytes.length); + try { + api.writeBytes(api.bufferAddress(handle), bytes, 0, bytes.length); + require(api.bufferFreeze(handle, bytes.length) == 0, "input freeze"); + return handle; + } catch (RuntimeException | Error failure) { + api.bufferRelease(handle); + throw failure; + } + } + + private void initialize() throws Exception { + owner = shared == null ? api.ownerNew(memoryLimit) : shared.owner; + require(owner != 0, "owner admission"); + driver = api.driverNew(owner, 0, BATCH_SIZE); + require(driver != 0, "driver initialization"); + wakeDriver.set(driver); + batch = allocate(40 * BATCH_SIZE); + descriptorBuffer = allocate(32); + descriptor = api.bufferAddress(descriptorBuffer); + if (certificate != null && privateKey != null) { + long cert = upload(certificate); + long key = 0; + long alpn = 0; + try { + key = upload(privateKey); + alpn = upload("\010http/1.1".getBytes(StandardCharsets.US_ASCII)); + tls = api.tlsServer(owner, cert, key, alpn); + require(tls != 0, "TLS context"); + } finally { + api.bufferRelease(cert); + if (key != 0) api.bufferRelease(key); + if (alpn != 0) api.bufferRelease(alpn); + } + } + if (shared != null && workerIndex != 0) { + bound.complete(0); + return; + } + byte[] address = InetAddress.getByName(host).getAddress(); + require(address.length == 4, "IPv4 listen address"); + long endpoint = allocate(24); + try { + api.bufferView(endpoint) + .order(ByteOrder.nativeOrder()) + .put(address) + .putShort(16, (short) port) + .putShort(18, (short) 4); + require(api.bufferFreeze(endpoint, 24) == 0, "endpoint"); + listener = api.socketListen(owner, driver, endpoint, 256, 1); + require(listener != 0, "listen"); + } finally { + api.bufferRelease(endpoint); + } + long boundAddress = allocate(24); + try { + require(api.socketAddress(driver, listener, 0, boundAddress) == 0, "bound address"); + int actualPort = + Short.toUnsignedInt( + api.bufferView(boundAddress).order(ByteOrder.nativeOrder()).getShort(16)); + require(api.socketAccept(owner, driver, listener) != 0, "accept submission"); + bound.complete(actualPort); + } finally { + api.bufferRelease(boundAddress); + } + } + + private void run() { + Throwable failure = null; + try { + initialize(); + ByteBuffer events = api.bufferView(batch).order(ByteOrder.nativeOrder()); + boolean progressed = false; + while (running.get()) { + Runnable task; + while ((task = tasks.poll()) != null) { + task.run(); + progressed = true; + } + // Flush work queued by Java before entering the blocking native poll. + int count = api.driverPoll(driver, progressed ? 0 : 10_000_000L, batch, BATCH_SIZE); + progressed = count > 0; + require(count >= 0, "poll"); + for (int i = 0; i < count; i++) { + int offset = i * 40; + event( + events.getLong(offset), + events.getLong(offset + 8), + events.getLong(offset + 16), + events.getLong(offset + 24), + events.getInt(offset + 32)); + } + } + } catch (Throwable error) { + failure = error; + bound.completeExceptionally(error); + } finally { + running.set(false); + wakeDriver.set(0); + try { + retire(); + } catch (Throwable error) { + if (failure == null) failure = error; + else failure.addSuppressed(error); + } + // Accepted continuations must run even if shutdown overtook the normal task phase. + // Late producers observe running=false and remove/reject their own queued task. + Runnable queued; + while ((queued = tasks.poll()) != null) { + if (queued instanceof ApplicationTask) { + try { + queued.run(); + } catch (Throwable error) { + if (failure == null) failure = error; + else failure.addSuppressed(error); + } + } + } + if (failure == null) stopped.complete(null); + else stopped.completeExceptionally(failure); + } + } + + private record RequestHead(String method, String target, List
headers, String version) {} + + /** Snapshot the ABI's head and span table before invoking application code. */ + private RequestHead requestHead(long exchange) { + ByteBuffer layout = ByteBuffer.wrap(api.bytes(exchange, 32)).order(ByteOrder.nativeOrder()); + int length = Math.toIntExact(layout.getLong(8)); + int slots = layout.getInt(24); + require(length >= 0 && slots >= 4 && (slots - 4) % 4 == 0, "request head layout"); + byte[] head = api.bytes(layout.getLong(0), length); + ByteBuffer spans = + ByteBuffer.wrap(api.bytes(layout.getLong(16), Math.multiplyExact(slots, 4))) + .order(ByteOrder.nativeOrder()); + List
headers = new ArrayList<>((slots - 4) / 4); + String method = headField(head, spans, 0); + String target = headField(head, spans, 2); + for (int slot = 4; slot < slots; slot += 4) { + headers.add(new Header(headField(head, spans, slot), headField(head, spans, slot + 2))); + } + return new RequestHead( + method, target, List.copyOf(headers), layout.get(29) == 0 ? "HTTP/1.0" : "HTTP/1.1"); + } + + private static String headField(byte[] head, ByteBuffer spans, int slot) { + int start = spans.getInt(slot * 4); + int end = spans.getInt((slot + 1) * 4); + java.util.Objects.checkFromToIndex(start, end, head.length); + return new String(head, start, end - start, StandardCharsets.ISO_8859_1); + } + + private InetSocketAddress socketAddress(long socket, int kind) { + require(api.socketAddress(driver, socket, kind, descriptorBuffer) == 0, "peer address"); + ByteBuffer view = api.bufferView(descriptorBuffer).order(ByteOrder.nativeOrder()); + byte[] bytes = new byte[view.getShort(18) == 4 ? 4 : 16]; + view.get(0, bytes); + try { + return new InetSocketAddress( + InetAddress.getByAddress(bytes), Short.toUnsignedInt(view.getShort(16))); + } catch (java.net.UnknownHostException impossible) { + throw new IllegalStateException(impossible); + } + } + + private void adopt(long socket) { + if (api.socketAdopt(owner, driver, socket) != 0) api.socketDiscard(socket); + else if (api.socketOption(driver, socket, 1, 1) != 0 + || (tls == 0 + ? api.socketHttp(owner, driver, socket, owner, 16384) + : api.socketHttpTls(owner, driver, socket, owner, 16384, tls)) + != 0) { + api.socketClose(driver, socket); + } else { + sockets.add(socket); + peers.put(socket, new Peer(socketAddress(socket, 0), socketAddress(socket, 1))); + } + } + + private void event(long operation, long socket, long value, long result, int kind) { + if (kind == 2) { + if (result == 0 && value != 0) { + if (shared == null) adopt(value); + else shared.accept(value); + } + require(api.socketAccept(owner, driver, listener) != 0, "accept rearm"); + return; + } + if (kind == TransportNative.EVENT_CLOSED) { + sockets.remove(socket); + peers.remove(socket); + for (Exchange exchange : List.copyOf(exchanges.values())) { + if (exchange.socket == socket) exchange.abort(new IOException("HTTP connection closed")); + } + return; + } + if (kind == TransportNative.EVENT_REQUEST) { + if (shared == null ? exchanges.size() >= exchangeLimit : !shared.admitExchange()) { + api.httpRelease(driver, value); + api.httpFree(driver, value); + return; + } + Exchange exchange = new Exchange(value, socket); + exchanges.put(value, exchange); + exchange.inbound.ended = (api.getByte(value + 31) & TransportNative.EXCHANGE_FLAG_BODY) == 0; + RequestHead head = requestHead(value); + Peer peer = peers.get(socket); + require(peer != null, "HTTP peer metadata"); + Request request = + new Request( + head.method(), + head.target(), + head.headers(), + exchange.inbound, + peer.local(), + peer.remote(), + tls != 0, + head.version()); + try { + exchange.reply = handler.handle(request).toCompletableFuture(); + exchange.reply.whenComplete( + (response, failure) -> { + exchange.selectedResponse = response; + if (!exchange.live || !running.get()) { + if (response != null) response.cancel(new IOException("HTTP exchange closed")); + return; + } + boolean accepted = + submit( + () -> { + if (!exchange.live) { + if (response != null) + response.sent.completeExceptionally( + new IOException("HTTP exchange closed")); + } else if (failure != null) exchange.abort(failure); + else if (response == null) + exchange.abort(new IOException("HTTP handler returned null")); + else exchange.respond(response); + }); + if (!accepted && response != null) + response.cancel(new IOException("HTTP server stopped")); + }); + } catch (Throwable failure) { + exchange.abort(failure); + } + return; + } + Exchange exchange = exchanges.get(value); + if (kind == TransportNative.EVENT_BODY) { + if (exchange == null || !exchange.live) api.httpSegmentRelease(driver, operation); + else { + exchange.inbound.segments.addLast(operation); + exchange.inbound.received += result; + if (result < 0 || exchange.inbound.received > bodyLimit) { + exchange.abort(new IOException("HTTP body limit exceeded")); + } else exchange.inbound.drain(); + } + } else if (exchange != null) { + if (kind == TransportNative.EVENT_BODY_END) { + if (result < 0) exchange.abort(new IOException("HTTP body aborted: " + result)); + else { + exchange.inbound.ended = true; + exchange.inbound.drain(); + } + } else if (kind == TransportNative.EVENT_PART_SENT && exchange.outbound != null) { + if (result < 0) exchange.abort(new IOException("HTTP response aborted: " + result)); + else exchange.outbound.sentPart(); + } else if (kind == TransportNative.EVENT_RESET || result < 0) { + exchange.abort(new IOException("HTTP stream aborted: " + result)); + } + } + } + + private final class Exchange { + final long id; + final long socket; + final Inbound inbound = new Inbound(this); + volatile boolean live = true; + volatile @Nullable Response selectedResponse; + @Nullable CompletableFuture reply; + @Nullable Outbound outbound; + + Exchange(long id, long socket) { + this.id = id; + this.socket = socket; + } + + void respond(Response response) { + try { + outbound = new Outbound(this, response); + outbound.begin(); + } catch (Throwable failure) { + abort(failure); + } + } + + void abort(Throwable failure) { + if (!live) return; + if (outbound != null) outbound.fail(failure); + else if (selectedResponse != null) selectedResponse.cancel(failure); + if (reply != null && !reply.isDone()) reply.cancel(false); + inbound.fail(failure); + api.httpRelease(driver, id); + finish(); + } + + void finish() { + if (!live) return; + live = false; + exchanges.remove(id); + if (shared != null) shared.exchanges.decrementAndGet(); + inbound.releaseSegments(); + api.httpFree(driver, id); + } + } + + private final class Inbound implements Flow.Publisher { + private Executor executor() { + return NativeHttpServer.this.executor; + } + + final Exchange exchange; + final ArrayDeque segments = new ArrayDeque<>(); + Flow.@Nullable Subscriber subscriber; + long demand; + long received; + boolean ended; + boolean cancelled; + boolean subscribed; + boolean complete; + + Inbound(Exchange exchange) { + this.exchange = exchange; + } + + private void reject(Flow.Subscriber next, Throwable failure) { + next.onSubscribe( + new Flow.Subscription() { + @Override + public void request(long n) {} + + @Override + public void cancel() {} + }); + next.onError(failure); + } + + @Override + public void subscribe(Flow.Subscriber next) { + boolean accepted = + submit( + () -> { + if (!exchange.live) { + reject(next, new IOException("HTTP exchange closed")); + return; + } + if (subscribed) { + next.onSubscribe( + new Flow.Subscription() { + @Override + public void request(long n) {} + + @Override + public void cancel() {} + }); + next.onError(new IllegalStateException("HTTP body permits one subscriber")); + return; + } + subscribed = true; + subscriber = next; + next.onSubscribe( + new Flow.Subscription() { + @Override + public void request(long n) { + submit( + () -> { + if (n <= 0) + exchange.abort( + new IllegalArgumentException("body demand must be positive")); + else { + demand = n > Long.MAX_VALUE - demand ? Long.MAX_VALUE : demand + n; + drain(); + } + }); + } + + @Override + public void cancel() { + submit( + () -> { + cancelled = true; + releaseSegments(); + }); + } + }); + drain(); + }); + if (!accepted) reject(next, new IOException("HTTP server stopped")); + } + + void drain() { + if (subscriber == null || complete) return; + try { + while (!cancelled && demand > 0 && !segments.isEmpty()) { + long segment = segments.removeFirst(); + byte[] bytes; + try { + bytes = api.bytes(api.getLong(segment), Math.toIntExact(api.getLong(segment + 8))); + } finally { + api.httpSegmentRelease(driver, segment); + } + demand--; + subscriber.onNext(bytes); + } + if (!cancelled && ended && segments.isEmpty()) { + complete = true; + subscriber.onComplete(); + } + } catch (Throwable failure) { + exchange.abort(failure); + } + } + + void releaseSegments() { + while (!segments.isEmpty()) api.httpSegmentRelease(driver, segments.removeFirst()); + } + + void fail(Throwable failure) { + boolean notify = !complete && !cancelled; + complete = true; + cancelled = true; + releaseSegments(); + if (subscriber != null && notify) { + try { + subscriber.onError(failure); + } catch (Throwable ignored) { + /* User callback failed. */ + } + } + } + } + + private final class Outbound implements Flow.Subscriber { + final Exchange exchange; + final Response response; + Flow.@Nullable Subscription subscription; + byte @Nullable [] pending; + int pendingOffset; + int inFlight; + boolean requested; + boolean ended; + boolean finalQueued; + boolean completeResponse; + boolean failed; + long prepared; + int preparedLength; + + Outbound(Exchange exchange, Response response) { + this.exchange = exchange; + this.response = response; + } + + void begin() { + response.sent.whenComplete( + (ignored, failure) -> { + if (failure != null) submit(() -> exchange.abort(failure)); + }); + byte[] completeBody = + response.body instanceof FixedBody fixed + && fixed.bytes.length == response.length + && fixed.bytes.length <= MAX_RESPONSE_PART + && api.supportsCompleteHttp1Responses() + ? fixed.bytes + : null; + completeResponse = completeBody != null; + long records = 0; + long bodyAddress = 0; + try { + if (!response.headers.isEmpty() || completeResponse) { + int tableBytes = Math.multiplyExact(response.headers.size(), 32); + List names = new ArrayList<>(); + List values = new ArrayList<>(); + int capacity = Math.addExact(tableBytes, completeBody == null ? 0 : completeBody.length); + for (Header header : response.headers) { + byte[] name = header.name().getBytes(StandardCharsets.US_ASCII); + byte[] value = header.value().getBytes(StandardCharsets.ISO_8859_1); + names.add(name); + values.add(value); + capacity = Math.addExact(capacity, Math.addExact(name.length, value.length)); + } + // One owner allocation holds both the records and their text. httpRespond + // reads everything synchronously, so no per-header leases or freezes are needed. + records = allocate(capacity); + ByteBuffer table = api.bufferView(records).order(ByteOrder.nativeOrder()); + long base = api.bufferAddress(records); + int offset = tableBytes; + for (int i = 0; i < response.headers.size(); i++) { + byte[] name = names.get(i); + byte[] value = values.get(i); + table.putLong(i * 32, base + offset).putLong(i * 32 + 8, name.length); + table.put(offset, name); + offset += name.length; + table.putLong(i * 32 + 16, base + offset).putLong(i * 32 + 24, value.length); + table.put(offset, value); + offset += value.length; + } + if (completeBody != null) { + bodyAddress = base + offset; + table.put(offset, completeBody); + } + } + require( + api.httpRespond( + driver, + exchange.id, + response.status, + records == 0 ? 0 : api.bufferAddress(records), + response.headers.size(), + bodyAddress, + response.length, + completeResponse + ? TransportNative.RESPOND_COMPLETE_CREDIT + : TransportNative.RESPOND_STREAM) + == 0, + "response head"); + inFlight = 1; + } finally { + if (records != 0) api.bufferRelease(records); + } + if (!completeResponse) response.body.subscribe(this); + } + + @Override + public void onSubscribe(Flow.Subscription next) { + if (!exchange.live || !running.get()) { + next.cancel(); + return; + } + boolean accepted = + submit( + () -> { + if (subscription != null || !exchange.live) next.cancel(); + else { + subscription = next; + advance(); + } + }); + if (!accepted) next.cancel(); + } + + @Override + public void onNext(byte[] bytes) { + submit( + () -> { + if (!exchange.live) return; + if (!requested || pending != null || ended) { + exchange.abort(new IllegalStateException("response publisher exceeded demand")); + return; + } + requested = false; + pending = bytes; + pendingOffset = 0; + advance(); + }); + } + + @Override + public void onError(Throwable failure) { + submit(() -> exchange.abort(failure)); + } + + @Override + public void onComplete() { + submit( + () -> { + ended = true; + requested = false; + advance(); + }); + } + + void advance() { + if (!exchange.live || failed || inFlight != 0) return; + try { + if (pending != null && pendingOffset == pending.length) { + pending = null; + pendingOffset = 0; + } + if (pending != null) { + if (prepared == 0) { + preparedLength = Math.min(MAX_RESPONSE_PART, pending.length - pendingOffset); + prepared = api.httpChunkPrepare(exchange.id, preparedLength, descriptor); + require(prepared != 0, "response part admission"); + api.writeBytes(api.getLong(descriptor), pending, pendingOffset, preparedLength); + } + int result = api.httpChunkSend(driver, exchange.id, prepared, preparedLength, 0); + if (result == -2) return; + if (result == 0 || result != -1) prepared = 0; + require(result == 0, "response part submission: " + result); + pendingOffset += preparedLength; + inFlight++; + } else if (ended) { + if (!finalQueued) { + if (prepared == 0) { + prepared = api.httpChunkPrepare(exchange.id, 0, descriptor); + require(prepared != 0, "final part admission"); + } + int result = + api.httpChunkSend(driver, exchange.id, prepared, 0, TransportNative.CHUNK_FINAL); + if (result == -2) return; + if (result == 0 || result != -1) prepared = 0; + require(result == 0, "final part submission: " + result); + finalQueued = true; + inFlight++; + } else { + exchange.finish(); + response.sent.complete(null); + } + } else if (subscription != null && !requested) { + requested = true; + subscription.request(1); + } + } catch (Throwable failure) { + exchange.abort(failure); + } + } + + void sentPart() { + require(inFlight > 0, "unexpected response credit"); + inFlight--; + if (completeResponse) { + exchange.finish(); + response.sent.complete(null); + } else advance(); + } + + void fail(Throwable failure) { + if (failed) return; + failed = true; + if (subscription != null) { + try { + subscription.cancel(); + } catch (Throwable error) { + if (failure != error) failure.addSuppressed(error); + } + } + if (prepared != 0) { + api.bufferRelease(prepared); + prepared = 0; + } + response.sent.completeExceptionally(failure); + } + } + + private void retire() { + IOException closing = new IOException("HTTP server stopped"); + for (Exchange exchange : List.copyOf(exchanges.values())) exchange.abort(closing); + if (driver != 0) { + for (long socket : sockets) api.socketClose(driver, socket); + if (listener != 0) api.socketClose(driver, listener); + api.httpRetire(driver); + long deadline = System.nanoTime() + 5_000_000_000L; + int status; + while ((status = api.driverRelease(driver)) == -2) { + require(System.nanoTime() < deadline, "driver retirement deadline"); + require(api.driverPoll(driver, 1_000_000L, batch, BATCH_SIZE) >= 0, "retirement poll"); + } + require(status == 0, "driver retirement"); + sockets.clear(); + peers.clear(); + } + if (tls != 0) api.tlsContextRelease(tls); + if (descriptorBuffer != 0) api.bufferRelease(descriptorBuffer); + if (batch != 0) api.bufferRelease(batch); + if (owner != 0 && shared == null) { + require(api.ownerUsed(owner) == 0, "native storage reclamation"); + require(api.ownerRelease(owner) == 0, "owner retirement"); + } + } + + /** Cancel exchanges and retire I/O; completion/failure is available through {@link #stopped}. */ + @Override + public void close() { + if (workerGroup != null) { + synchronized (this) { + closed = true; + } + workerGroup.close(); + } else { + stopLoop(); + awaitLoop(); + } + } + + private void stopLoop() { + synchronized (this) { + closed = true; + running.set(false); + if (thread == null) { + bound.completeExceptionally( + new java.util.concurrent.CancellationException("HTTP server closed before start")); + stopped.complete(null); + return; + } + } + long handle = wakeDriver.get(); + if (handle != 0) api.driverWake(handle); + } + + private void awaitLoop() { + Thread current = thread; + if (current != null && current != Thread.currentThread()) { + try { + current.join(6000); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + require(!current.isAlive(), "event loop shutdown deadline"); + } + } +} diff --git a/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt b/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt new file mode 100644 index 0000000..f9d3dd9 --- /dev/null +++ b/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt @@ -0,0 +1,335 @@ +/* + * Copyright (c) 2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +@file:OptIn(io.ktor.utils.io.InternalAPI::class) + +package dev.elide.bemo.ktor + +import dev.elide.bemo.transport.TransportNative +import dev.elide.bemo.transport.http.NativeHttpServer +import io.ktor.events.Events +import io.ktor.http.* +import io.ktor.http.content.OutgoingContent +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.request.RequestCookies +import io.ktor.server.response.ResponseHeaders +import io.ktor.utils.io.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Flow +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.* +import kotlinx.coroutines.future.await + +/** Native HTTP/1.1 engine; application routing and transformations remain Ktor's. */ +object Bemo : ApplicationEngineFactory { + override fun configuration(configure: BemoEngine.Configuration.() -> Unit) = + BemoEngine.Configuration().apply(configure) + + override fun create( + environment: ApplicationEnvironment, + monitor: Events, + developmentMode: Boolean, + configuration: BemoEngine.Configuration, + applicationProvider: () -> Application, + ) = BemoEngine(environment, monitor, developmentMode, configuration, applicationProvider) +} + +class BemoEngine( + environment: ApplicationEnvironment, + monitor: Events, + developmentMode: Boolean, + private val configuration: Configuration, + private val applicationProvider: () -> Application, +) : BaseApplicationEngine(environment, monitor, developmentMode) { + class Configuration : BaseApplicationEngine.Configuration() { + var nativeMemoryLimit: Long = 256L * 1024 * 1024 + var requestBodyLimit: Long = 16L * 1024 * 1024 + var exchangeLimit: Int = 8192 + var nativeWorkers: Int = 1 + var dispatchCallsOnNativeWorker: Boolean = false + var certificatePem: ByteArray? = null + var privateKeyPem: ByteArray? = null + } + + private val servers = mutableListOf() + private val workerDispatchers = ConcurrentHashMap() + + override fun start(wait: Boolean): BemoEngine { + val api = + TransportNative.load() ?: error("Bemo backend unavailable; select the stock Ktor engine") + val resolved = mutableListOf() + try { + for (connector in configuration.connectors) { + val server = + NativeHttpServer( + api, + connector.host, + connector.port, + configuration.nativeMemoryLimit, + configuration.requestBodyLimit, + configuration.exchangeLimit, + { request -> handle(request, connector) }, + configuration.certificatePem, + configuration.privateKeyPem, + configuration.nativeWorkers, + ) + servers.add(server) + val port = server.start().toCompletableFuture().join() + resolved.add(connector.withPort(port)) + println( + "Bemo native HTTP: backend=${api.javaClass.name} port=$port tls=${configuration.certificatePem != null}" + ) + } + resolvedConnectorsDeferred.complete(resolved) + monitor.raise(ServerReady, environment) + if (wait) for (server in servers) server.stopped().toCompletableFuture().join() + return this + } catch (failure: Throwable) { + stop(0, 0) + throw failure + } + } + + private fun handle( + input: NativeHttpServer.Request, + connector: EngineConnectorConfig, + ): CompletableFuture { + val application = applicationProvider() + val ready = CompletableFuture() + val dispatcher = + if (configuration.dispatchCallsOnNativeWorker) + workerDispatchers.computeIfAbsent(input.executor()) { it.asCoroutineDispatcher() } + else Dispatchers.Default + val context = + application.coroutineContext + SupervisorJob(application.coroutineContext[Job]) + dispatcher + val call = BemoCall(application, context, input, connector, ready) + val task = + CoroutineScope(context).launch { + try { + pipeline.execute(call, Unit) + call.response.finish() + } catch (failure: Throwable) { + call.response.fail(failure) + } finally { + call.close() + context[Job]?.cancel() + } + } + ready.whenComplete { response, failure -> + if (failure != null) task.cancel() + else response.sent().whenComplete { _, error -> if (error != null) task.cancel() } + } + return ready + } + + override fun stop(gracePeriodMillis: Long, timeoutMillis: Long) { + monitor.raise(ApplicationStopPreparing, environment) + for (server in servers) { + server.close() + server.stopped().toCompletableFuture().join() + } + servers.clear() + workerDispatchers.clear() + } +} + +private class BemoCall( + application: Application, + override val coroutineContext: CoroutineContext, + input: NativeHttpServer.Request, + connector: EngineConnectorConfig, + ready: CompletableFuture, +) : BaseApplicationCall(application) { + override val request = BemoRequest(this, input, connector) + override val response = BemoResponse(this, ready) + + init { + putResponseAttribute() + } + + fun close() { + request.close() + } +} + +private class BemoRequest( + private val nativeCall: BemoCall, + private val input: NativeHttpServer.Request, + connector: EngineConnectorConfig, +) : BaseApplicationRequest(nativeCall) { + override val engineHeaders = Headers.build { + for (header in input.headers()) append(header.name(), header.value()) + } + private val channel = ByteChannel(autoFlush = true) + private var subscription: Flow.Subscription? = null + override val engineReceiveChannel: ByteReadChannel = channel + override val queryParameters: Parameters = + parseQueryString(input.target().substringAfter('?', "")) + override val rawQueryParameters: Parameters = + parseQueryString(input.target().substringAfter('?', ""), decode = false) + override val cookies = RequestCookies(this) + override val local: RequestConnectionPoint = + object : RequestConnectionPoint { + override val scheme = if (input.secure()) "https" else "http" + override val version = input.version() + override val uri = input.target() + override val method = HttpMethod.parse(input.method()) + override val localPort = input.localAddress().port + override val serverPort = + engineHeaders[HttpHeaders.Host]?.substringAfterLast(':', "")?.toIntOrNull() ?: localPort + override val localHost = input.localAddress().hostString + override val serverHost = engineHeaders[HttpHeaders.Host]?.substringBefore(':') ?: localHost + override val localAddress = input.localAddress().address.hostAddress + override val remoteHost = input.remoteAddress().hostString + override val remoteAddress = input.remoteAddress().address.hostAddress + override val remotePort = input.remoteAddress().port + @Suppress("OVERRIDE_DEPRECATION") override val host = serverHost + @Suppress("OVERRIDE_DEPRECATION") override val port = serverPort + } + + init { + input + .body() + .subscribe( + object : Flow.Subscriber { + override fun onSubscribe(next: Flow.Subscription) { + subscription = next + next.request(1) + } + + override fun onNext(bytes: ByteArray) { + CoroutineScope(nativeCall.coroutineContext).launch( + start = CoroutineStart.UNDISPATCHED + ) { + try { + channel.writeFully(bytes) + channel.flush() + subscription?.request(1) + } catch (failure: Throwable) { + subscription?.cancel() + channel.close(failure) + } + } + } + + override fun onError(failure: Throwable) { + channel.close(failure) + } + + override fun onComplete() { + channel.close() + } + } + ) + } + + fun close() { + subscription?.cancel() + if (!channel.isClosedForWrite) channel.cancel() + } +} + +private class BemoResponse( + private val nativeCall: BemoCall, + private val ready: CompletableFuture, +) : BaseApplicationResponse(nativeCall) { + private val values = HeadersBuilder() + private var nativeResponse: NativeHttpServer.Response? = null + private var output: ByteChannel? = null + override val headers = + object : ResponseHeaders() { + override fun engineAppendHeader(name: String, value: String) { + values.append(name, value) + } + + override fun getEngineHeaderNames() = values.names().toList() + + override fun getEngineHeaderValues(name: String) = values.getAll(name).orEmpty() + } + + override fun setStatus(statusCode: HttpStatusCode) {} + + private fun send(body: Flow.Publisher, length: Long) { + val responseHeaders = mutableListOf() + values.build().forEach { name, fields -> + if ( + !name.equals(HttpHeaders.ContentLength, true) && + !name.equals(HttpHeaders.TransferEncoding, true) + ) { + for (value in fields) responseHeaders.add(NativeHttpServer.Header(name, value)) + } + } + val response = NativeHttpServer.Response(status()?.value ?: 200, responseHeaders, length, body) + nativeResponse = response + check(ready.complete(response)) { "HTTP response already selected" } + } + + override suspend fun respondFromBytes(bytes: ByteArray) { + send(NativeHttpServer.fixedBody(bytes), bytes.size.toLong()) + nativeResponse!!.sent().await() + } + + override suspend fun respondNoContent(content: OutgoingContent.NoContent) { + send(NativeHttpServer.fixedBody(ByteArray(0)), 0) + nativeResponse!!.sent().await() + } + + override suspend fun responseChannel(): ByteWriteChannel { + val channel = ByteChannel(autoFlush = true) + output = channel + val publisher = + Flow.Publisher { subscriber -> + subscriber.onSubscribe( + object : Flow.Subscription { + private var cancelled = false + + override fun request(n: Long) { + if (cancelled) return + if (n != 1L) { + subscriber.onError( + IllegalArgumentException("one native response credit required") + ) + return + } + CoroutineScope(nativeCall.coroutineContext).launch { + try { + val bytes = ByteArray(8192) + val count = channel.readAvailable(bytes) + if (count < 0) subscriber.onComplete() + else subscriber.onNext(bytes.copyOf(count)) + } catch (failure: Throwable) { + subscriber.onError(failure) + } + } + } + + override fun cancel() { + cancelled = true + if (!channel.isClosedForWrite) channel.cancel() + } + } + ) + } + send(publisher, values[HttpHeaders.ContentLength]?.toLongOrNull() ?: -1) + return channel + } + + override suspend fun respondUpgrade(upgrade: OutgoingContent.ProtocolUpgrade) { + throw UnsupportedOperationException("HTTP upgrades require the stock Ktor engine") + } + + suspend fun finish() { + output?.flushAndClose() + nativeResponse?.sent()?.await() + } + + fun fail(failure: Throwable) { + if (!ready.completeExceptionally(failure)) nativeResponse?.cancel(failure) + output?.close(failure) + } +} + diff --git a/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java new file mode 100644 index 0000000..7851703 --- /dev/null +++ b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.spring; + +import dev.elide.bemo.transport.http.NativeHttpServer; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.AbstractServerHttpRequest; +import org.springframework.http.server.reactive.AbstractServerHttpResponse; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import reactor.adapter.JdkFlowAdapter; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Spring WebFlux request/response adapter over the native HTTP event loop, with no Netty + * dependency. + */ +public final class BemoHttpHandlerAdapter implements NativeHttpServer.Handler { + private final HttpHandler handler; + private final String origin; + + public BemoHttpHandlerAdapter(HttpHandler handler, String origin) { + URI uri = URI.create(origin); + if (!List.of("http", "https").contains(uri.getScheme()) || uri.getRawAuthority() == null) { + throw new IllegalArgumentException("HTTP origin required"); + } + this.handler = handler; + this.origin = origin; + } + + @Override + public CompletionStage handle(NativeHttpServer.Request request) { + CompletableFuture ready = new CompletableFuture<>(); + AtomicReference application = new AtomicReference<>(); + Response response = new Response(ready); + ready.whenComplete( + (nativeResponse, failure) -> { + if (failure != null) { + Disposable task = application.get(); + if (task != null) task.dispose(); + } else { + nativeResponse + .sent() + .whenComplete( + (ignored, writeFailure) -> { + if (writeFailure != null) { + Disposable task = application.get(); + if (task != null) task.dispose(); + } + }); + } + }); + try { + Request input = new Request(request, origin); + Disposable task = + handler + .handle(input, response) + .subscribe( + ignored -> {}, + response::fail, + () -> response.setComplete().subscribe(ignored -> {}, response::fail)); + application.set(task); + if (ready.isCompletedExceptionally()) task.dispose(); + } catch (Throwable failure) { + response.fail(failure); + } + return ready; + } + + private static final class Request extends AbstractServerHttpRequest { + private final NativeHttpServer.Request nativeRequest; + + Request(NativeHttpServer.Request request, String origin) { + super(HttpMethod.valueOf(request.method()), uri(request, origin), "", headers(request)); + nativeRequest = request; + } + + private static HttpHeaders headers(NativeHttpServer.Request request) { + HttpHeaders headers = new HttpHeaders(); + for (NativeHttpServer.Header header : request.headers()) + headers.add(header.name(), header.value()); + return headers; + } + + private static URI uri(NativeHttpServer.Request request, String origin) { + URI target = URI.create(request.target()); + if (target.isAbsolute()) return target; + URI base = URI.create(origin); + String authority = headers(request).getFirst(HttpHeaders.HOST); + if (authority == null) authority = base.getRawAuthority(); + return URI.create(base.getScheme() + "://" + authority + request.target()); + } + + @Override + public Flux getBody() { + return JdkFlowAdapter.flowPublisherToFlux(nativeRequest.body()) + .map(bytes -> DefaultDataBufferFactory.sharedInstance.wrap(bytes)); + } + + @Override + protected MultiValueMap initCookies() { + MultiValueMap cookies = new LinkedMultiValueMap<>(); + List fields = getHeaders().get(HttpHeaders.COOKIE); + if (fields == null) return cookies; + for (String field : fields) { + for (String token : field.split(";")) { + int equals = token.indexOf('='); + if (equals <= 0) continue; + String name = token.substring(0, equals).trim(); + String value = token.substring(equals + 1).trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + if (!name.isEmpty()) cookies.add(name, new HttpCookie(name, value)); + } + } + return cookies; + } + + @Override + public java.net.InetSocketAddress getLocalAddress() { + return nativeRequest.localAddress(); + } + + @Override + public java.net.InetSocketAddress getRemoteAddress() { + return nativeRequest.remoteAddress(); + } + + @Override + protected @Nullable SslInfo initSslInfo() { + return null; + } + + @Override + @SuppressWarnings("unchecked") + public T getNativeRequest() { + return (T) nativeRequest; + } + } + + private static final class Response extends AbstractServerHttpResponse { + private final CompletableFuture ready; + private NativeHttpServer.@Nullable Response nativeResponse; + + Response(CompletableFuture ready) { + super(DefaultDataBufferFactory.sharedInstance); + this.ready = ready; + } + + @Override + @SuppressWarnings("unchecked") + public T getNativeResponse() { + return (T) this; + } + + @Override + protected void applyStatusCode() {} + + @Override + protected void applyHeaders() {} + + @Override + protected void applyCookies() { + getCookies() + .forEach( + (name, cookies) -> + cookies.forEach( + cookie -> getHeaders().add(HttpHeaders.SET_COOKIE, cookie.toString()))); + } + + @Override + protected Mono writeWithInternal(Publisher body) { + if (body instanceof Mono) { + return Mono.from(body) + .map(buffer -> copyBody(buffer)) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release) + .defaultIfEmpty(new byte[0]) + .flatMap(bytes -> send(Flux.empty(), bytes)); + } + return send(Flux.from(body)); + } + + @Override + protected Mono writeAndFlushWithInternal( + Publisher> body) { + // Every native part is flushed; concatMap preserves each inner publisher's order and demand. + return send(Flux.from(body).concatMap(Flux::from, 1)); + } + + @Override + public Mono setComplete() { + return !isCommitted() ? doCommit(() -> send(Flux.empty(), new byte[0])) : Mono.empty(); + } + + private static byte[] copyBody(DataBuffer buffer) { + try { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } finally { + DataBufferUtils.release(buffer); + } + } + + private Mono send(Flux body) { + return send(body, null); + } + + private Mono send(Flux body, byte @Nullable [] completeBody) { + return Mono.defer( + () -> { + List headers = new ArrayList<>(); + getHeaders() + .forEach( + (name, values) -> { + if (!name.equalsIgnoreCase(HttpHeaders.CONTENT_LENGTH) + && !name.equalsIgnoreCase(HttpHeaders.TRANSFER_ENCODING)) { + for (String value : values) + headers.add(new NativeHttpServer.Header(name, value)); + } + }); + Flux bytes = + body.map(buffer -> copyBody(buffer)) + .doOnDiscard(DataBuffer.class, DataBufferUtils::release); + long length = getHeaders().getContentLength(); + if (completeBody != null && length < 0) length = completeBody.length; + // No body for the bodyless HTTP statuses; the native codec owns framing headers. + int status = getStatusCode() == null ? 200 : getStatusCode().value(); + NativeHttpServer.Response response = + new NativeHttpServer.Response( + status, + headers, + status == 204 || status == 304 ? 0 : length, + completeBody == null + ? JdkFlowAdapter.publisherToFlowPublisher(bytes) + : NativeHttpServer.fixedBody(completeBody)); + nativeResponse = response; + if (!ready.complete(response)) { + return Mono.error(new IllegalStateException("HTTP response already selected")); + } + return Mono.fromCompletionStage(response.sent()); + }); + } + + void fail(Throwable failure) { + if (!ready.completeExceptionally(failure) && nativeResponse != null) + nativeResponse.cancel(failure); + } + } +} diff --git a/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java new file mode 100644 index 0000000..b408977 --- /dev/null +++ b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +package dev.elide.bemo.spring; + +import dev.elide.bemo.transport.TransportNative; +import dev.elide.bemo.transport.http.NativeHttpServer; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.util.Base64; +import java.util.Enumeration; +import java.util.concurrent.TimeUnit; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.WebServerException; +import org.springframework.boot.web.server.reactive.AbstractReactiveWebServerFactory; +import org.springframework.http.server.reactive.HttpHandler; + +/** Boot factory that preserves the application's HttpHandler and uses Bemo's native HTTP codec. */ +public final class BemoReactiveWebServerFactory extends AbstractReactiveWebServerFactory { + private final TransportNative api; + private long nativeMemoryLimit = 256L * 1024 * 1024; + private long requestBodyLimit = 16L * 1024 * 1024; + private int exchangeLimit = 8192; + private int nativeWorkers = 1; + + public BemoReactiveWebServerFactory(TransportNative api) { + this.api = api; + } + + public void setNativeMemoryLimit(long bytes) { + nativeMemoryLimit = bytes; + } + + public void setRequestBodyLimit(long bytes) { + requestBodyLimit = bytes; + } + + public void setExchangeLimit(int limit) { + exchangeLimit = limit; + } + + public void setNativeWorkers(int workers) { + if (workers < 1) throw new IllegalArgumentException("native workers"); + nativeWorkers = workers; + } + + @Override + public WebServer getWebServer(HttpHandler handler) { + try { + String host = getAddress() == null ? "0.0.0.0" : getAddress().getHostAddress(); + Ssl ssl = getSsl(); + boolean encrypted = ssl != null && ssl.isEnabled(); + byte[] certificate = null; + byte[] key = null; + if (encrypted) { + if (ssl.getClientAuth() != null && ssl.getClientAuth() != Ssl.ClientAuth.NONE + || ssl.getCiphers() != null + || ssl.getEnabledProtocols() != null + || !ssl.getServerNameBundles().isEmpty()) { + throw new IllegalArgumentException( + "Bemo HTTP TLS currently requires default protocol/cipher policy, one server identity" + + " and no client authentication"); + } + SslBundle bundle = getSslBundle(); + KeyStore store = bundle.getStores().getKeyStore(); + if (store == null) throw new IllegalArgumentException("TLS key store missing"); + String alias = bundle.getKey().getAlias(); + if (alias == null) { + Enumeration aliases = store.aliases(); + while (aliases.hasMoreElements()) { + String candidate = aliases.nextElement(); + if (store.isKeyEntry(candidate)) { + alias = candidate; + break; + } + } + } + if (alias == null) throw new IllegalArgumentException("TLS private key missing"); + String password = bundle.getKey().getPassword(); + if (password == null) password = bundle.getStores().getKeyStorePassword(); + Key privateKey = store.getKey(alias, password == null ? null : password.toCharArray()); + Certificate[] chain = store.getCertificateChain(alias); + if (privateKey == null || chain == null || chain.length == 0) + throw new IllegalArgumentException("TLS identity incomplete"); + StringBuilder certificates = new StringBuilder(); + for (Certificate entry : chain) certificates.append(pem("CERTIFICATE", entry.getEncoded())); + certificate = certificates.toString().getBytes(StandardCharsets.US_ASCII); + key = pem("PRIVATE KEY", privateKey.getEncoded()).getBytes(StandardCharsets.US_ASCII); + } + BemoHttpHandlerAdapter adapter = + new BemoHttpHandlerAdapter( + handler, (encrypted ? "https" : "http") + "://" + host + ":" + getPort()); + NativeHttpServer server = + new NativeHttpServer( + api, + host, + getPort(), + nativeMemoryLimit, + requestBodyLimit, + exchangeLimit, + adapter, + certificate, + key, + nativeWorkers); + return new WebServer() { + private int boundPort = -1; + + @Override + public void start() { + try { + boundPort = server.start().toCompletableFuture().get(30, TimeUnit.SECONDS); + System.out.println( + "Bemo native HTTP: backend=" + + api.getClass().getName() + + " port=" + + boundPort + + " tls=" + + encrypted); + } catch (Exception failure) { + server.close(); + throw new WebServerException("Bemo HTTP startup failed", failure); + } + } + + @Override + public void stop() { + server.close(); + try { + server.stopped().toCompletableFuture().get(1, TimeUnit.SECONDS); + } catch (Exception failure) { + throw new WebServerException("Bemo HTTP retirement failed", failure); + } + } + + @Override + public int getPort() { + return boundPort; + } + }; + } catch (Exception failure) { + throw new WebServerException("Bemo HTTP configuration failed", failure); + } + } + + private static String pem(String type, byte[] encoded) { + return "-----BEGIN " + + type + + "-----\n" + + Base64.getMimeEncoder(64, new byte[] {'\n'}).encodeToString(encoded) + + "\n-----END " + + type + + "-----\n"; + } +} diff --git a/tests/transport/java/BackendTransport.java b/tests/transport/java/BackendTransport.java index e914e26..3375b55 100644 --- a/tests/transport/java/BackendTransport.java +++ b/tests/transport/java/BackendTransport.java @@ -28,6 +28,11 @@ public java.nio.ByteBuffer nativeView(long address, int size) { return delegate.nativeView(address, size); } + @Override + public boolean supportsCompleteHttp1Responses() { + return delegate.supportsCompleteHttp1Responses(); + } + @Override public long gzipNew(long workload, int level) { return delegate.gzipNew(workload, level); diff --git a/tests/transport/java/CapiTlsChannelTest.java b/tests/transport/java/CapiTlsChannelTest.java index a2174bf..34ec905 100644 --- a/tests/transport/java/CapiTlsChannelTest.java +++ b/tests/transport/java/CapiTlsChannelTest.java @@ -23,5 +23,7 @@ public static void main(String[] args) throws Exception { NativeSslEngineTest.verify(api, cert, key); NativeSslPolicyTest.verify(api, cert, key); NativeSslInteropTest.verify(api, Path.of(args[0]), Path.of(args[1])); + NativeHttpServerTest.verify(api, Path.of(args[0]), Path.of(args[1])); + NativeHttpWorkersTest.verify(api, Path.of(args[0]), Path.of(args[1])); } } diff --git a/tests/transport/java/NativeHttpServerTest.java b/tests/transport/java/NativeHttpServerTest.java new file mode 100644 index 0000000..0afaebd --- /dev/null +++ b/tests/transport/java/NativeHttpServerTest.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import dev.elide.bemo.transport.FfmTransportNative; +import dev.elide.bemo.transport.TransportNative; +import dev.elide.bemo.transport.http.NativeHttpServer; +import java.io.ByteArrayOutputStream; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; + +/** The same lifecycle/streaming contract runs through FFM and Bali's direct image backend. */ +public final class NativeHttpServerTest { + private static void require(boolean ok, String operation) { + if (!ok) throw new AssertionError(operation); + } + + private static Flow.Publisher body(List chunks) { + return subscriber -> + subscriber.onSubscribe( + new Flow.Subscription() { + int next; + boolean ended; + + @Override + public void request(long n) { + require(n == 1, "one response item after physical write credit"); + if (ended) return; + if (next == chunks.size()) { + ended = true; + subscriber.onComplete(); + } else subscriber.onNext(chunks.get(next++)); + } + + @Override + public void cancel() { + ended = true; + } + }); + } + + private static CompletableFuture echo( + NativeHttpServer.Request request, boolean complete) { + CompletableFuture result = new CompletableFuture<>(); + request + .body() + .subscribe( + new Flow.Subscriber<>() { + Flow.Subscription subscription; + final ByteArrayOutputStream input = new ByteArrayOutputStream(); + + @Override + public void onSubscribe(Flow.Subscription next) { + subscription = next; + next.request(1); + } + + @Override + public void onNext(byte[] bytes) { + input.writeBytes(bytes); + subscription.request(1); + } + + @Override + public void onError(Throwable failure) { + result.completeExceptionally(failure); + } + + @Override + public void onComplete() { + byte[] prefix = + (request.method() + " " + request.target() + " ") + .getBytes(StandardCharsets.US_ASCII); + byte[] payload = input.toByteArray(); + if (request.target().startsWith("/headers")) { + String values = + request.headers().stream() + .filter(header -> header.name().equalsIgnoreCase("x-probe")) + .map(NativeHttpServer.Header::value) + .collect(java.util.stream.Collectors.joining("|")); + payload = + (request.version() + " " + values).getBytes(StandardCharsets.ISO_8859_1); + } + result.complete( + new NativeHttpServer.Response( + 200, + List.of( + new NativeHttpServer.Header( + "content-type", "application/octet-stream")), + request.target().contains("chunked") ? -1 : prefix.length + payload.length, + complete + ? NativeHttpServer.fixedBody(concat(prefix, payload)) + : body(List.of(prefix, payload)))); + } + }); + return result; + } + + private static byte[] concat(byte[] prefix, byte[] payload) { + byte[] result = Arrays.copyOf(prefix, prefix.length + payload.length); + System.arraycopy(payload, 0, result, prefix.length, payload.length); + return result; + } + + public static void verify(TransportNative api, Path certificate, Path key) throws Exception { + verify(api, certificate, key, 1); + } + + public static void verify(TransportNative api, Path certificate, Path key, int workers) + throws Exception { + verifyMode(api, certificate, key, workers, false); + verifyMode(api, certificate, key, workers, true); + } + + private static void verifyMode( + TransportNative api, Path certificate, Path key, int workers, boolean complete) + throws Exception { + NativeHttpServer unstarted = + new NativeHttpServer( + api, + "127.0.0.1", + 0, + 1024 * 1024, + 1024, + 16, + request -> echo(request, complete), + null, + null, + workers); + unstarted.close(); + unstarted.stopped().toCompletableFuture().get(1, TimeUnit.SECONDS); + require( + unstarted.start().toCompletableFuture().isCompletedExceptionally(), + "closed server cannot restart"); + for (boolean tls : new boolean[] {false, true}) { + byte[] cert = tls ? Files.readAllBytes(certificate) : null; + byte[] privateKey = tls ? Files.readAllBytes(key) : null; + NativeHttpServer server = + new NativeHttpServer( + api, + "127.0.0.1", + 0, + 16 * 1024 * 1024, + 2 * 1024 * 1024, + 1024, + request -> echo(request, complete), + cert, + privateKey, + workers); + int port = server.start().toCompletableFuture().get(5, TimeUnit.SECONDS); + HttpClient.Builder builder = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1); + if (tls) { + java.security.KeyStore store = java.security.KeyStore.getInstance("PKCS12"); + store.load(null, null); + try (var input = Files.newInputStream(certificate)) { + store.setCertificateEntry( + "fixture", + java.security.cert.CertificateFactory.getInstance("X.509") + .generateCertificate(input)); + } + var trust = + javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + trust.init(store); + var ssl = javax.net.ssl.SSLContext.getInstance("TLS"); + ssl.init(null, trust.getTrustManagers(), null); + builder.sslContext(ssl); + } + try (HttpClient client = builder.build()) { + String base = (tls ? "https" : "http") + "://127.0.0.1:" + port; + var first = + client.send( + HttpRequest.newBuilder(URI.create(base + "/ping?value=1")).build(), + HttpResponse.BodyHandlers.ofString()); + require( + first.statusCode() == 200 && first.body().equals("GET /ping?value=1 "), + "empty request and exact target"); + var headers = + client.send( + HttpRequest.newBuilder(URI.create(base + "/headers?escaped=%2F")) + .header("X-Probe", "alpha") + .header("X-Probe", "beta") + .build(), + HttpResponse.BodyHandlers.ofString()); + require( + headers.body().equals("GET /headers?escaped=%2F HTTP/1.1 alpha|beta"), + "duplicate header order, exact escaped target and HTTP version"); + var head = + client.send( + HttpRequest.newBuilder(URI.create(base + "/head")) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + require( + head.statusCode() == 200 + && head.body().length == 0 + && head.headers().firstValueAsLong("content-length").orElse(-1) + == "HEAD /head ".length(), + "HEAD metadata without body"); + byte[] bytes = new byte[1024 * 1024]; + for (int i = 0; i < bytes.length; i++) bytes[i] = (byte) (i * 31); + var fixed = + client.send( + HttpRequest.newBuilder(URI.create(base + "/large")) + .POST(HttpRequest.BodyPublishers.ofByteArray(bytes)) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + byte[] prefix = "POST /large ".getBytes(StandardCharsets.US_ASCII); + require( + fixed.statusCode() == 200 && fixed.body().length == prefix.length + bytes.length, + "large response length"); + require( + Arrays.equals( + Arrays.copyOfRange(fixed.body(), prefix.length, fixed.body().length), bytes), + "large body split at native response window"); + var chunked = + client.send( + HttpRequest.newBuilder(URI.create(base + "/chunked")) + .POST( + HttpRequest.BodyPublishers.ofInputStream( + () -> new java.io.ByteArrayInputStream(bytes))) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + prefix = "POST /chunked ".getBytes(StandardCharsets.US_ASCII); + require( + chunked.statusCode() == 200 && chunked.body().length == prefix.length + bytes.length, + "chunked response length"); + require( + Arrays.equals( + Arrays.copyOfRange(chunked.body(), prefix.length, chunked.body().length), bytes), + "chunked request/response data"); + if (!tls) { + try (Socket latin = new Socket("127.0.0.1", port)) { + latin.setSoTimeout(5000); + latin + .getOutputStream() + .write( + "GET /headers?latin=1 HTTP/1.0\r\nX-Probe: caf\u00e9\r\nx-probe:\r\n\r\n" + .getBytes(StandardCharsets.ISO_8859_1)); + String wire = + new String(latin.getInputStream().readAllBytes(), StandardCharsets.ISO_8859_1); + require( + wire.endsWith("GET /headers?latin=1 HTTP/1.0 caf\u00e9|"), + "HTTP/1.0, Latin-1 and empty duplicate header values"); + } + try (Socket malformed = new Socket("127.0.0.1", port)) { + malformed.setSoTimeout(5000); + malformed + .getOutputStream() + .write( + "GET / HTTP/1.1\r\nHost: a\r\nBad Header: value\r\n\r\n" + .getBytes(StandardCharsets.US_ASCII)); + String status = + new java.io.BufferedReader( + new java.io.InputStreamReader( + malformed.getInputStream(), StandardCharsets.US_ASCII)) + .readLine(); + require( + status == null || status.startsWith("HTTP/1.1 400"), + "malformed request rejected: " + status); + } + var after = + client.send( + HttpRequest.newBuilder(URI.create(base + "/after")).build(), + HttpResponse.BodyHandlers.ofString()); + require(after.body().equals("GET /after "), "malformed peer leaves server healthy"); + } + } finally { + server.close(); + server.stopped().toCompletableFuture().get(1, TimeUnit.SECONDS); + } + } + System.out.println( + "Native HTTP framework loop: streaming, TLS, malformed input and retirement passed"); + } + + public static void main(String[] args) throws Exception { + verify(new FfmTransportNative(Path.of(args[0])), Path.of(args[1]), Path.of(args[2])); + } +} diff --git a/tests/transport/java/NativeHttpWorkersTest.java b/tests/transport/java/NativeHttpWorkersTest.java new file mode 100644 index 0000000..c1cd0ea --- /dev/null +++ b/tests/transport/java/NativeHttpWorkersTest.java @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2026 Elide Technologies, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ +import dev.elide.bemo.transport.FfmTransportNative; +import dev.elide.bemo.transport.TransportNative; +import dev.elide.bemo.transport.http.NativeHttpServer; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** Portable worker ownership, shared admission, failure and pending-accept retirement. */ +public final class NativeHttpWorkersTest { + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static final class Tracking extends BackendTransport { + final AtomicInteger owners = new AtomicInteger(); + final AtomicInteger releases = new AtomicInteger(); + final AtomicInteger drivers = new AtomicInteger(); + final AtomicLong owner = new AtomicLong(); + final Set accepted = ConcurrentHashMap.newKeySet(); + final CountDownLatch acceptedTwo = new CountDownLatch(2); + final CountDownLatch discarded = new CountDownLatch(1); + final CountDownLatch unblock = new CountDownLatch(1); + volatile boolean failDriver; + volatile boolean blockWorker; + volatile boolean blockPoll; + final java.util.concurrent.atomic.AtomicBoolean pollClaimed = + new java.util.concurrent.atomic.AtomicBoolean(); + final CountDownLatch pollEntered = new CountDownLatch(1); + final CountDownLatch pollResume = new CountDownLatch(1); + final CountDownLatch closeWake = new CountDownLatch(1); + + Tracking(TransportNative api) { + super(api); + } + + @Override + public long ownerNew(long limit) { + owners.incrementAndGet(); + long handle = super.ownerNew(limit); + require(handle != 0 && owner.compareAndSet(0, handle), "one shared workload"); + return handle; + } + + @Override + public long driverNew(long workload, int backend, int limit) { + require(workload == owner.get(), "every driver uses the shared workload"); + int ordinal = drivers.getAndIncrement(); + if (failDriver && ordinal == 1) return 0; + if (blockWorker && Thread.currentThread().getName().equals("bemo-http-1")) { + try { + require(unblock.await(10, TimeUnit.SECONDS), "worker unblocked"); + } catch (InterruptedException error) { + throw new AssertionError(error); + } + } + return super.driverNew(workload, backend, limit); + } + + @Override + public int driverPoll(long driver, long timeout, long batch, int maximum) { + if (blockPoll + && Thread.currentThread().getName().equals("bemo-http-0") + && pollClaimed.compareAndSet(false, true)) { + pollEntered.countDown(); + try { + require(pollResume.await(10, TimeUnit.SECONDS), "poll resumed"); + } catch (InterruptedException error) { + throw new AssertionError(error); + } + } + int count = super.driverPoll(driver, timeout, batch, maximum); + ByteBuffer events = bufferView(batch).order(ByteOrder.nativeOrder()); + for (int i = 0; i < count; i++) { + int offset = i * 40; + if (events.getInt(offset + 32) == 2 && events.getLong(offset + 24) == 0) { + long socket = events.getLong(offset + 16); + if (socket != 0) { + accepted.add(socket); + acceptedTwo.countDown(); + } + } + } + return count; + } + + @Override + public int driverWake(long driver) { + int result = super.driverWake(driver); + if (Thread.currentThread().getName().equals("http-worker-close-test")) closeWake.countDown(); + return result; + } + + @Override + public int socketAdopt(long workload, long driver, long socket) { + int result = super.socketAdopt(workload, driver, socket); + accepted.remove(socket); + return result; + } + + @Override + public int socketDiscard(long socket) { + int result = super.socketDiscard(socket); + if (accepted.remove(socket)) discarded.countDown(); + return result; + } + + @Override + public int ownerRelease(long handle) { + require( + ownerUsed(handle) == 0 && accepted.isEmpty(), "all native storage and accepts retired"); + int result = super.ownerRelease(handle); + require(result == 0 && owner.compareAndSet(handle, 0), "shared workload released once"); + releases.incrementAndGet(); + return result; + } + + void reclaimed(int expected) { + require( + owners.get() == expected && releases.get() == expected && owner.get() == 0, + "shared admission budget is not multiplied by workers"); + require(accepted.isEmpty(), "no pending accepted sockets"); + } + } + + private static NativeHttpServer.Response response(String text) { + byte[] bytes = text.getBytes(StandardCharsets.US_ASCII); + Flow.Publisher body = + subscriber -> + subscriber.onSubscribe( + new Flow.Subscription() { + boolean ended; + + @Override + public void request(long n) { + require(n == 1, "one response credit"); + if (!ended) { + ended = true; + subscriber.onNext(bytes); + subscriber.onComplete(); + } + } + + @Override + public void cancel() { + ended = true; + } + }); + return new NativeHttpServer.Response(200, List.of(), bytes.length, body); + } + + private static NativeHttpServer server( + Tracking api, int port, long memory, int exchanges, NativeHttpServer.Handler handler) { + return new NativeHttpServer( + api, "127.0.0.1", port, memory, 1024 * 1024, exchanges, handler, null, null, 4); + } + + private static Socket request(int port) throws Exception { + Socket socket = new Socket("127.0.0.1", port); + socket.setSoTimeout(5000); + socket.getOutputStream().write("GET / HTTP/1.0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + return socket; + } + + private static void participation(TransportNative backend) throws Exception { + Tracking api = new Tracking(backend); + Set workers = ConcurrentHashMap.newKeySet(); + java.util.concurrent.atomic.AtomicReference executor = + new java.util.concurrent.atomic.AtomicReference<>(); + NativeHttpServer server = + server( + api, + 0, + 16 * 1024 * 1024, + 32, + request -> { + Thread owner = Thread.currentThread(); + workers.add(owner.getName()); + executor.compareAndSet(null, request.executor()); + CompletableFuture ready = new CompletableFuture<>(); + request + .executor() + .execute( + () -> { + try { + require( + Thread.currentThread() == owner, + "application callback returns to connection owner"); + ready.complete(response("worker")); + } catch (Throwable failure) { + ready.completeExceptionally(failure); + } + }); + return ready; + }); + try { + int port = server.start().toCompletableFuture().get(5, TimeUnit.SECONDS); + for (int i = 0; i < 8; i++) { + try (Socket socket = request(port)) { + require( + new String(socket.getInputStream().readAllBytes(), StandardCharsets.US_ASCII) + .endsWith("worker"), + "worker response"); + } + } + require(workers.size() == 4 && api.drivers.get() == 4, "all four workers handle requests"); + } finally { + server.close(); + } + server.stopped().toCompletableFuture().get(5, TimeUnit.SECONDS); + try { + executor + .get() + .execute( + () -> { + throw new AssertionError("closed executor ran work"); + }); + throw new AssertionError("closed executor accepted work"); + } catch (java.util.concurrent.RejectedExecutionException expected) { + // A continuation cannot become stranded in the retired worker's task queue. + } + api.reclaimed(1); + } + + private static void sharedExchangeLimit(TransportNative backend) throws Exception { + Tracking api = new Tracking(backend); + AtomicInteger calls = new AtomicInteger(); + CountDownLatch entered = new CountDownLatch(1); + CompletableFuture blocked = new CompletableFuture<>(); + NativeHttpServer server = + server( + api, + 0, + 16 * 1024 * 1024, + 1, + request -> { + if (calls.incrementAndGet() == 1) { + entered.countDown(); + return blocked; + } + return CompletableFuture.completedFuture(response("next")); + }); + try { + int port = server.start().toCompletableFuture().get(5, TimeUnit.SECONDS); + try (Socket first = request(port)) { + require(entered.await(5, TimeUnit.SECONDS), "first handler admitted"); + try (Socket refused = request(port)) { + try { + require(refused.getInputStream().read() == -1, "over-budget connection closed"); + } catch (java.net.SocketException closed) { + /* A reset also refuses the request. */ + } + } + require(calls.get() == 1, "exchange limit applies across workers"); + NativeHttpServer.Response reply = response("first"); + blocked.complete(reply); + reply.sent().toCompletableFuture().get(5, TimeUnit.SECONDS); + require( + new String(first.getInputStream().readAllBytes(), StandardCharsets.US_ASCII) + .endsWith("first"), + "first response finishes"); + } + try (Socket next = request(port)) { + require( + new String(next.getInputStream().readAllBytes(), StandardCharsets.US_ASCII) + .endsWith("next"), + "shared admission recovers after completion"); + } + } finally { + server.close(); + } + server.stopped().toCompletableFuture().get(5, TimeUnit.SECONDS); + api.reclaimed(1); + } + + private static void startupFailure(TransportNative backend, boolean lowMemory) throws Exception { + Tracking api = new Tracking(backend); + api.failDriver = !lowMemory; + NativeHttpServer server = + server( + api, + 0, + lowMemory ? 16384 : 16 * 1024 * 1024, + 32, + request -> CompletableFuture.completedFuture(response("unused"))); + try { + try { + server.start().toCompletableFuture().get(10, TimeUnit.SECONDS); + throw new AssertionError("partial startup must fail"); + } catch (java.util.concurrent.ExecutionException + | java.util.concurrent.CancellationException expected) { + // Every started worker must still retire its native storage. + } + } finally { + server.close(); + } + require( + server.stopped().toCompletableFuture().isCompletedExceptionally(), + "startup failure reported"); + api.reclaimed(1); + } + + private static void pendingAccepts(TransportNative backend) throws Exception { + Tracking api = new Tracking(backend); + api.blockWorker = true; + int port; + try (java.net.ServerSocket reserve = new java.net.ServerSocket(0)) { + port = reserve.getLocalPort(); + } + NativeHttpServer server = + server( + api, + port, + 16 * 1024 * 1024, + 32, + request -> CompletableFuture.completedFuture(response("unused"))); + server.start(); + Socket first = null, second = null; + try { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (first == null) { + try { + first = new Socket("127.0.0.1", port); + } catch (java.net.ConnectException notReady) { + require(System.nanoTime() < deadline, "listener ready before last worker"); + Thread.sleep(5); + } + } + second = new Socket("127.0.0.1", port); + require(api.acceptedTwo.await(5, TimeUnit.SECONDS), "two accepted connections"); + CompletableFuture close = CompletableFuture.runAsync(server::close); + try { + require( + api.discarded.await(5, TimeUnit.SECONDS), + "queued accept discarded before worker startup"); + } finally { + api.unblock.countDown(); + } + close.get(10, TimeUnit.SECONDS); + } finally { + api.unblock.countDown(); + server.close(); + if (first != null) first.close(); + if (second != null) second.close(); + } + server.stopped().toCompletableFuture().get(5, TimeUnit.SECONDS); + api.reclaimed(1); + } + + private static void queuedExecutorShutdown(TransportNative backend) throws Exception { + Tracking api = new Tracking(backend); + CompletableFuture entered = new CompletableFuture<>(); + CompletableFuture pending = new CompletableFuture<>(); + NativeHttpServer server = + server( + api, + 0, + 16 * 1024 * 1024, + 32, + request -> { + api.blockPoll = true; + entered.complete(request); + return pending; + }); + AtomicInteger executed = new AtomicInteger(); + CompletableFuture closed = new CompletableFuture<>(); + try { + int port = server.start().toCompletableFuture().get(5, TimeUnit.SECONDS); + try (Socket client = request(port)) { + NativeHttpServer.Request request = entered.get(5, TimeUnit.SECONDS); + require(api.pollEntered.await(5, TimeUnit.SECONDS), "worker paused between task phases"); + request.executor().execute(executed::incrementAndGet); + Thread closer = + new Thread( + () -> { + try { + server.close(); + closed.complete(null); + } catch (Throwable failure) { + closed.completeExceptionally(failure); + } + }, + "http-worker-close-test"); + closer.start(); + try { + require(api.closeWake.await(5, TimeUnit.SECONDS), "shutdown overtook queued callback"); + } finally { + api.pollResume.countDown(); + } + closed.get(10, TimeUnit.SECONDS); + } + } finally { + api.pollResume.countDown(); + server.close(); + } + server.stopped().toCompletableFuture().get(5, TimeUnit.SECONDS); + require(executed.get() == 1, "accepted callback drained exactly once during shutdown"); + api.reclaimed(1); + } + + public static void verify(TransportNative api, Path certificate, Path key) throws Exception { + Tracking lifecycle = new Tracking(api); + NativeHttpServerTest.verify(lifecycle, certificate, key, 4); + lifecycle.reclaimed(4); + participation(api); + queuedExecutorShutdown(api); + sharedExchangeLimit(api); + startupFailure(api, false); + startupFailure(api, true); + pendingAccepts(api); + System.out.println( + "Native HTTP workers: shared admission, distribution, failure and retirement passed"); + } + + public static void main(String[] args) throws Exception { + verify(new FfmTransportNative(Path.of(args[0])), Path.of(args[1]), Path.of(args[2])); + } +} diff --git a/tools/build.py b/tools/build.py index 0f3994d..953947f 100644 --- a/tools/build.py +++ b/tools/build.py @@ -23,7 +23,7 @@ VERSIONS = json.loads((ROOT / "tools/versions.json").read_text()) VERSION = (ROOT / ".version").read_text().strip() ELIDE = os.environ.get("ELIDE", "elide") -MODULES = ("api", "ffm", "native-image", "netty", "bali") +MODULES = ("api", "ffm", "native-image", "netty", "bali", "spring", "ktor") MAVEN_GROUP = "dev.elide.bemo" MAVEN_PATH = MAVEN_GROUP.replace(".", "/") REPOSITORY = "https://github.com/elide-dev/bemo" @@ -55,6 +55,15 @@ def netty(): "netty-transport-native-unix-common")] +def spring(): + return [jar_dependency("org.springframework", name, VERSIONS["spring"]) + for name in ("spring-web", "spring-core", "spring-beans")] + [ + jar_dependency("io.projectreactor", "reactor-core", VERSIONS["reactor"]), + jar_dependency("org.reactivestreams", "reactive-streams", "1.0.4"), + jar_dependency("org.springframework.boot", "spring-boot", VERSIONS["spring_boot"]), + jar_dependency("org.springframework.boot", "spring-boot-web-server", VERSIONS["spring_boot"])] + + def benchmark_netty(): """Stock native transports are benchmark dependencies, never published dependencies.""" system = platform.system() @@ -118,6 +127,15 @@ def compile_java(output, inputs, dependencies=(), lint="all"): "-cp", classpath([*dependencies, jspecify()]), "-d", output, *inputs) +def compile_kotlin(output, inputs): + shutil.rmtree(output, ignore_errors=True) + output.mkdir(parents=True) + resolved = subprocess.check_output([ELIDE, "classpath", "runtime"], cwd=ROOT, text=True).strip() + runtime = os.pathsep.join(path for path in resolved.split(os.pathsep) if Path(path).is_file()) + run(ELIDE, "kotlinc", "--", "-jvm-target", VERSIONS["jvm_release"], "-no-stdlib", "-no-reflect", + "-classpath", runtime + os.pathsep + str(classes("api")), "-d", output, *inputs) + + def bali_api_classpath(): """Compile-only contract; never package a duplicate of the image-owned guest API.""" provided = os.environ.get("BALI_API_CLASSPATH") @@ -160,7 +178,12 @@ def jvm(): cp += netty() if module == "bali": cp += bali_api_classpath() - compile_java(classes(module), sources(module), cp) + if module == "spring": + cp += spring() + if module == "ktor": + compile_kotlin(classes(module), sorted((ROOT / "packages/ktor/src/main/kotlin").rglob("*.kt"))) + else: + compile_java(classes(module), sources(module), cp) resources = ROOT / "packages" / module / "src/main/resources" if resources.is_dir(): shutil.copytree(resources, classes(module), dirs_exist_ok=True) @@ -294,7 +317,7 @@ def test_transport(native_image=False, reports=None, agent=(), native=None, env= "NativeTlsClosePromiseTest", "NativeTransferTest", "NativeJfrTest", "NativeReentrantCloseTest", "NativeReceiveAllocatorTest", "NativeSslEngineTest", "NativeSslInteropTest", "NativeSslPolicyTest", "NativeDirectAllocatorTest", - "StandaloneTransportCheck"): + "StandaloneTransportCheck", "NativeHttpServerTest", "NativeHttpWorkersTest"): reports.run(contract, [os.environ.get("BEMO_TEST_JAVA", java_tool("java")), *agent, "--enable-native-access=ALL-UNNAMED", "-ea", "-cp", classpath([output, *cp]), contract, native or library(), cert, key], timeout=90, cwd=ROOT, env=env) @@ -367,6 +390,8 @@ def fmt(check=False): java_files = [p for module in MODULES for p in sources(module)] + sorted((ROOT / "tests").rglob("*.java")) + sorted((ROOT / "benchmarks").rglob("*.java")) + sorted((ROOT / "examples").glob("*/src/**/*.java")) flags = ["--dry-run", "--set-exit-if-changed"] if check else ["--replace"] run(ELIDE, "java", "--", "-jar", formatter, *flags, *java_files) + kotlin_files = sorted((ROOT / "packages/ktor/src/main/kotlin").rglob("*.kt")) + run(ELIDE, "ktfmt", "--", *(["--dry-run", "--set-exit-if-changed"] if check else []), *kotlin_files) def check(): @@ -469,7 +494,7 @@ def package(): for name in ("LICENSE", "NOTICE"): shutil.copy2(ROOT / name, metadata / name) jar(f"{prefix}.jar", classes(module)) - jar(f"{prefix}-sources.jar", ROOT / "packages" / module / "src/main/java") + jar(f"{prefix}-sources.jar", ROOT / "packages" / module / ("src/main/kotlin" if module == "ktor" else "src/main/java")) docs = BUILD / "javadoc" / module shutil.rmtree(docs, ignore_errors=True) cp = [jspecify()] if module == "api" else [classes("api"), jspecify()] @@ -479,8 +504,14 @@ def package(): cp += netty() if module == "bali": cp += bali_api_classpath() + if module == "spring": + cp += spring() # Javadoc is run by Elide's JVM toolchain, not a second build system. - run(ELIDE, "java", "--", "-m", "jdk.javadoc/jdk.javadoc.internal.tool.Main", "-quiet", + if module == "ktor": + docs.mkdir(parents=True, exist_ok=True) + (docs / "README.txt").write_text("Ktor engine API documentation is included in the source artifact.\n") + else: + run(ELIDE, "java", "--", "-m", "jdk.javadoc/jdk.javadoc.internal.tool.Main", "-quiet", "-notimestamp", "-Werror", "-Xdoclint:all,-missing", "--release", VERSIONS["jvm_release"], "-d", docs, "-classpath", classpath(cp) or str(classes(module)), *sources(module)) jar(f"{prefix}-javadoc.jar", docs) @@ -491,6 +522,12 @@ def package(): if module == "netty": dependencies += [("io.netty", name, VERSIONS["netty"], "compile") for name in ( "netty-transport", "netty-handler", "netty-codec-http2", "netty-transport-native-unix-common")] + if module == "spring": + dependencies += [("org.springframework", "spring-web", VERSIONS["spring"], "compile"), + ("org.springframework.boot", "spring-boot-web-server", VERSIONS["spring_boot"], "compile"), + ("io.projectreactor", "reactor-core", VERSIONS["reactor"], "compile")] + if module == "ktor": + dependencies += [("io.ktor", "ktor-server-core-jvm", VERSIONS["ktor"], "compile")] pom(f"{prefix}.pom", artifact, dependencies) # Attach separate dynamic and static native classifiers; keep base JARs portable. static_name = "bemo_ffi.lib" if os.name == "nt" else "libbemo_ffi.a" diff --git a/tools/versions.json b/tools/versions.json index 9580d67..40ebf0c 100644 --- a/tools/versions.json +++ b/tools/versions.json @@ -48,5 +48,8 @@ }, "ktor": "3.6.0", "kotlin": "2.4.20", - "tcnative": "2.0.84.Final" + "tcnative": "2.0.84.Final", + "spring": "7.0.9", + "reactor": "3.8.7", + "spring_boot": "4.1.1" } From 6620d7a9eee9ef27cb266603d1da1c7faf5e2d9e Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Wed, 14 Oct 2026 12:05:21 -0700 Subject: [PATCH 4/4] fix(build): qualify framework adapters against current upstream checks --- docs/http-frameworks.md | 9 +- docs/reference/c-abi.md | 5 +- .../bemo/transport/http/NativeHttpServer.java | 91 +++++++++++-------- .../transport/bali/BaliTransportNative.java | 38 -------- .../main/kotlin/dev/elide/bemo/ktor/Bemo.kt | 1 - .../bemo/spring/BemoHttpHandlerAdapter.java | 47 ++++++---- .../spring/BemoReactiveWebServerFactory.java | 2 +- tests/java/dev/elide/bemo/Contract.java | 3 +- tools/check_java.py | 4 +- 9 files changed, 98 insertions(+), 102 deletions(-) diff --git a/docs/http-frameworks.md b/docs/http-frameworks.md index 80f46bd..e6b8b5f 100644 --- a/docs/http-frameworks.md +++ b/docs/http-frameworks.md @@ -5,6 +5,12 @@ Spring WebFlux adapters. These modules are local candidates; release performance and lifecycle qualification are still in progress. The Rust seam and generated image imports remain unchanged. +Bemo parses HTTP wire bytes and encodes response headers and framing in Rust. +The Java loop consumes parsed native events and span metadata, materializes the +framework request, and hands response records back to the native encoder. Ktor +and Spring retain application routing and body codecs; neither adapter runs a +second HTTP wire parser. + `NativeHttpServer` lives in `bemo-api`. Drivers, exchange handles and delivered body segments stay on its thread. Request publishers copy a segment into managed storage only when a subscriber requests it, then release the native segment and @@ -77,7 +83,8 @@ option remains experimental until differential and serving checks qualify it. `NativeHttpServer.fixedBody(bytes)` identifies a known body whose bytes remain unchanged until `Response.sent()` completes. Bodies up to the native response-part limit use one HTTP/1 encode and one physical completion when metadata capability -`CAP_HTTP1_COMPLETE_CREDIT` is present. Legacy backends and larger or length-mismatched +`CAP_HTTP1_COMPLETE_CREDIT` (bit `4`) is present. Bit `2` remains reserved for +upstream native allocation; it does not imply complete-response support. Legacy backends and larger or length-mismatched bodies retain publisher streaming. Ktor byte responses and Spring single-buffer `Mono` responses use this path; arbitrary publishers retain incremental demand. diff --git a/docs/reference/c-abi.md b/docs/reference/c-abi.md index f59071e..2fd242b 100644 --- a/docs/reference/c-abi.md +++ b/docs/reference/c-abi.md @@ -20,6 +20,7 @@ Query the metadata ABI before resolving anything else. Capabilities are additive | `BEMO_ABI_VERSION` | `1` | | `BEMO_CAP_TRANSPORT_V3` | `UINT64_C(1)` | | `BEMO_CAP_NATIVE_ALLOC` | `UINT64_C(2)` | +| `BEMO_CAP_HTTP1_COMPLETE_CREDIT` | `UINT64_C(4)` | ### Functions @@ -73,6 +74,8 @@ The transport boundary shared by the FFM and Native Image bindings. Handles are | Name | Value | | --- | --- | +| `ELIDE_TRANSPORT_PANIC` | `(-900)` | +| `ELIDE_TRANSPORT_POISONED` | `(-901)` | | `ELIDE_TRANSPORT_ABI_VERSION` | `3` | ### Types @@ -359,7 +362,7 @@ int32_t elide_transport_http_view(uint64_t exchange, uint32_t kind, uint32_t ind #### `elide_transport_http_respond` -headers: count records of {name address, name length, value address, value length}. flags bit 1 (RESPOND_STREAM) sends the head only and ignores body: bodyLength then declares content-length, or UINT64_MAX for chunked. Parts follow through http_chunk_send. +headers: count records of {name address, name length, value address, value length}. Flag 4 (RESPOND_COMPLETE_CREDIT), advertised by Bemo capability 2, requests one PART_SENT after physical completion of a complete HTTP/1 response. It rejects streaming and HTTP/2 combinations. Freeing suppresses reports while retaining wire leases. Flag 2 (RESPOND_STREAM) sends the head only and ignores body: bodyLength then declares content-length, or UINT64_MAX for chunked. Parts follow through http_chunk_send. ```c int32_t elide_transport_http_respond(uint64_t driver, uint64_t exchange, uint32_t status, const uint64_t *headers, uint32_t count, const uint8_t *body, uint64_t bodyLength, uint32_t flags); diff --git a/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java b/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java index f54424c..571f129 100644 --- a/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java +++ b/packages/api/src/main/java/dev/elide/bemo/transport/http/NativeHttpServer.java @@ -60,7 +60,13 @@ public static Flow.Publisher fixedBody(byte[] bytes) { return new FixedBody(java.util.Objects.requireNonNull(bytes)); } - private record FixedBody(byte[] bytes) implements Flow.Publisher { + private static final class FixedBody implements Flow.Publisher { + private final byte[] bytes; + + FixedBody(byte[] bytes) { + this.bytes = bytes; + } + @Override public void subscribe(Flow.Subscriber subscriber) { subscriber.onSubscribe( @@ -166,7 +172,8 @@ void discard(TransportNative api) { this, i); workers.add(worker); - worker.stopped.whenComplete((ignored, error) -> workerStopped(error)); + var unusedStopObserver = + worker.stopped.whenComplete((ignored, error) -> workerStopped(error)); } } @@ -176,15 +183,18 @@ synchronized CompletionStage start() { try { owner = parent.api.ownerNew(parent.memoryLimit); require(owner != 0, "shared owner admission"); - CompletableFuture.allOf( - workers.stream().map(worker -> worker.bound).toArray(CompletableFuture[]::new)) - .whenComplete( - (ignored, error) -> { - if (!closing) { - if (error != null) fail(error); - else parent.bound.complete(workers.get(0).bound.join()); - } - }); + var unusedStartObserver = + CompletableFuture.allOf( + workers.stream() + .map(worker -> worker.bound) + .toArray(CompletableFuture[]::new)) + .whenComplete( + (ignored, error) -> { + if (!closing) { + if (error != null) fail(error); + else parent.bound.complete(workers.get(0).bound.join()); + } + }); for (NativeHttpServer worker : workers) worker.start(); } catch (Throwable error) { fail(error); @@ -456,6 +466,8 @@ public CompletionStage stopped() { return stopped; } + // Wake only when the caller is a different owner thread. + @SuppressWarnings("ReferenceEquality") private boolean submit(Runnable task) { if (!running.get()) return false; tasks.add(task); @@ -696,28 +708,29 @@ private void event(long operation, long socket, long value, long result, int kin head.version()); try { exchange.reply = handler.handle(request).toCompletableFuture(); - exchange.reply.whenComplete( - (response, failure) -> { - exchange.selectedResponse = response; - if (!exchange.live || !running.get()) { - if (response != null) response.cancel(new IOException("HTTP exchange closed")); - return; - } - boolean accepted = - submit( - () -> { - if (!exchange.live) { - if (response != null) - response.sent.completeExceptionally( - new IOException("HTTP exchange closed")); - } else if (failure != null) exchange.abort(failure); - else if (response == null) - exchange.abort(new IOException("HTTP handler returned null")); - else exchange.respond(response); - }); - if (!accepted && response != null) - response.cancel(new IOException("HTTP server stopped")); - }); + var unusedReplyObserver = + exchange.reply.whenComplete( + (response, failure) -> { + exchange.selectedResponse = response; + if (!exchange.live || !running.get()) { + if (response != null) response.cancel(new IOException("HTTP exchange closed")); + return; + } + boolean accepted = + submit( + () -> { + if (!exchange.live) { + if (response != null) + response.sent.completeExceptionally( + new IOException("HTTP exchange closed")); + } else if (failure != null) exchange.abort(failure); + else if (response == null) + exchange.abort(new IOException("HTTP handler returned null")); + else exchange.respond(response); + }); + if (!accepted && response != null) + response.cancel(new IOException("HTTP server stopped")); + }); } catch (Throwable failure) { exchange.abort(failure); } @@ -939,10 +952,11 @@ private final class Outbound implements Flow.Subscriber { } void begin() { - response.sent.whenComplete( - (ignored, failure) -> { - if (failure != null) submit(() -> exchange.abort(failure)); - }); + var unusedTransmissionObserver = + response.sent.whenComplete( + (ignored, failure) -> { + if (failure != null) submit(() -> exchange.abort(failure)); + }); byte[] completeBody = response.body instanceof FixedBody fixed && fixed.bytes.length == response.length @@ -1112,6 +1126,8 @@ void sentPart() { } else advance(); } + // Throwable identity is required to avoid illegal self-suppression. + @SuppressWarnings("ReferenceEquality") void fail(Throwable failure) { if (failed) return; failed = true; @@ -1185,6 +1201,7 @@ private void stopLoop() { if (handle != 0) api.driverWake(handle); } + @SuppressWarnings("ReferenceEquality") // Never join the current owner thread. private void awaitLoop() { Thread current = thread; if (current != null && current != Thread.currentThread()) { diff --git a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java index 8bbc011..ff6be74 100644 --- a/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java +++ b/packages/bali/src/main/java/dev/elide/bemo/transport/bali/BaliTransportNative.java @@ -706,52 +706,14 @@ public int tlsRelease(long session) { return tlsRelease0(session); } - private static long engineContextNew0( - long workload, - int flags, - long certificates, - long certificatesLength, - long key, - long keyLength, - long alpn, - long alpnLength) { - return BemoNative.elide_transport_engine_context_new( - workload, flags, certificates, certificatesLength, key, keyLength, alpn, alpnLength); - } - private static int engineContextRelease0(long context) { return BemoNative.elide_transport_engine_context_release(context); } - private static long engineNew0(long workload, long context, long name, long nameLength) { - return BemoNative.elide_transport_engine_new(workload, context, name, nameLength); - } - - private static long engineWrap0( - long engine, long source, long sourceLength, long destination, long destinationLength) { - return BemoNative.elide_transport_engine_wrap( - engine, source, sourceLength, destination, destinationLength); - } - - private static long engineUnwrap0( - long engine, - long source, - long sourceLength, - long destination, - long destinationLength, - int flags) { - return BemoNative.elide_transport_engine_unwrap( - engine, source, sourceLength, destination, destinationLength, flags); - } - private static long engineControl0(long engine, int operation) { return BemoNative.elide_transport_engine_control(engine, operation); } - private static int engineInfo0(long engine, int kind, int index, long output, long capacity) { - return BemoNative.elide_transport_engine_info(engine, kind, index, output, capacity); - } - private static int engineRelease0(long engine) { return BemoNative.elide_transport_engine_release(engine); } diff --git a/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt b/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt index f9d3dd9..6618d8f 100644 --- a/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt +++ b/packages/ktor/src/main/kotlin/dev/elide/bemo/ktor/Bemo.kt @@ -332,4 +332,3 @@ private class BemoResponse( output?.close(failure) } } - diff --git a/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java index 7851703..a6c0a30 100644 --- a/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java +++ b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoHttpHandlerAdapter.java @@ -52,23 +52,24 @@ public CompletionStage handle(NativeHttpServer.Reques CompletableFuture ready = new CompletableFuture<>(); AtomicReference application = new AtomicReference<>(); Response response = new Response(ready); - ready.whenComplete( - (nativeResponse, failure) -> { - if (failure != null) { - Disposable task = application.get(); - if (task != null) task.dispose(); - } else { - nativeResponse - .sent() - .whenComplete( - (ignored, writeFailure) -> { - if (writeFailure != null) { - Disposable task = application.get(); - if (task != null) task.dispose(); - } - }); - } - }); + var unusedCancellationObserver = + ready.whenComplete( + (nativeResponse, failure) -> { + if (failure != null) { + Disposable task = application.get(); + if (task != null) task.dispose(); + } else { + nativeResponse + .sent() + .whenComplete( + (ignored, writeFailure) -> { + if (writeFailure != null) { + Disposable task = application.get(); + if (task != null) task.dispose(); + } + }); + } + }); try { Request input = new Request(request, origin); Disposable task = @@ -122,7 +123,7 @@ protected MultiValueMap initCookies() { List fields = getHeaders().get(HttpHeaders.COOKIE); if (fields == null) return cookies; for (String field : fields) { - for (String token : field.split(";")) { + for (String token : field.split(";", -1)) { int equals = token.indexOf('='); if (equals <= 0) continue; String name = token.substring(0, equals).trim(); @@ -152,7 +153,10 @@ public java.net.InetSocketAddress getRemoteAddress() { } @Override - @SuppressWarnings("unchecked") + @SuppressWarnings({ + "unchecked", + "TypeParameterUnusedInFormals" + }) // Spring native-access contract. public T getNativeRequest() { return (T) nativeRequest; } @@ -168,7 +172,10 @@ private static final class Response extends AbstractServerHttpResponse { } @Override - @SuppressWarnings("unchecked") + @SuppressWarnings({ + "unchecked", + "TypeParameterUnusedInFormals" + }) // Spring native-access contract. public T getNativeResponse() { return (T) this; } diff --git a/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java index b408977..fd478eb 100644 --- a/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java +++ b/packages/spring/src/main/java/dev/elide/bemo/spring/BemoReactiveWebServerFactory.java @@ -58,7 +58,7 @@ public WebServer getWebServer(HttpHandler handler) { byte[] certificate = null; byte[] key = null; if (encrypted) { - if (ssl.getClientAuth() != null && ssl.getClientAuth() != Ssl.ClientAuth.NONE + if ((ssl.getClientAuth() != null && ssl.getClientAuth() != Ssl.ClientAuth.NONE) || ssl.getCiphers() != null || ssl.getEnabledProtocols() != null || !ssl.getServerNameBundles().isEmpty()) { diff --git a/tests/java/dev/elide/bemo/Contract.java b/tests/java/dev/elide/bemo/Contract.java index 6f95693..056ab12 100644 --- a/tests/java/dev/elide/bemo/Contract.java +++ b/tests/java/dev/elide/bemo/Contract.java @@ -7,7 +7,8 @@ private Contract() {} public static void verify(TransportNative transport) { transport.requireCompatible(); if (transport.abiVersion() != 1 - || (transport.capabilities() & (TransportNative.CAP_TRANSPORT_V3 | TransportNative.CAP_NATIVE_ALLOC)) + || (transport.capabilities() + & (TransportNative.CAP_TRANSPORT_V3 | TransportNative.CAP_NATIVE_ALLOC)) != (TransportNative.CAP_TRANSPORT_V3 | TransportNative.CAP_NATIVE_ALLOC)) { throw new AssertionError("Unexpected metadata ABI or transport capabilities"); } diff --git a/tools/check_java.py b/tools/check_java.py index 195ae57..2cc4002 100644 --- a/tools/check_java.py +++ b/tools/check_java.py @@ -5,7 +5,7 @@ import subprocess import contextlib -from build import BUILD, ELIDE, MODULES, ROOT, VERSIONS, classpath, deps, jar_dependency, netty, run, sdk, sources +from build import BUILD, ELIDE, MODULES, ROOT, VERSIONS, bali_api_classpath, classpath, deps, jar_dependency, netty, run, sdk, sources, spring def compiler(): @@ -26,7 +26,7 @@ def analyze(kind, inputs, output, **kwargs): if kind == "error-prone" else [jar_dependency("org.checkerframework", "checker", VERSIONS["checker"]), jar_dependency("org.checkerframework", "checker-util", VERSIONS["checker"])]) - cp = [*sdk(), *netty(), jar_dependency("org.jspecify", "jspecify", VERSIONS["jspecify"]), + cp = [*sdk(), *netty(), *spring(), *bali_api_classpath(), jar_dependency("org.jspecify", "jspecify", VERSIONS["jspecify"]), jar_dependency("org.checkerframework", "checker-qual", VERSIONS["checker"])] processors += [jar_dependency("org.checkerframework", "checker-qual", VERSIONS["checker"])] flags = (["-proc:none", "-XDcompilePolicy=simple", "--should-stop=ifError=FLOW",