Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_bridge-round7.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera_macro/Cargo.toml":"Patch","crates/vespera_inprocess/Cargo.toml":"Patch","crates/vespera_jni/Cargo.toml":"Patch","libs/vespera-bridge/build.gradle.kts":"Patch","libs/vespera-bridge-gradle-plugin/build.gradle.kts":"Patch"},"note":"vespera-bridge: a VesperaRequest InputStream read that fails because the native side closed the source after the response drained no longer fails the send; DispatchEvent.mode reports SYNC / STREAMING when a DIRECT request is downgraded or re-routed, and an unknown-app 404 reports its path; isInitialized() is published only after pending configure* values were applied; the public dispatchDirectPooled heap fallback parks on a virtual thread instead of pinning the carrier; Gradle plugin: extraNativeLibs without cargoRoot / targetDir bundles only the prebuilt libraries; docs on DIRECT overflow, version check, configureStreaming ranges and JaCoCo merge of isolatedJvmTest fixed","date":"2026-11-15T00:00:00.000Z"}
3 changes: 2 additions & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -956,7 +956,7 @@ jobs:
# Regenerates the bridge's JaCoCo report now that demo-app has run, so
# it also credits the classes only reachable with a loaded cdylib — the
# JNI wrappers and the Spring proxy, which the unit suite cannot drive.
# The report task reads demo-app/build/jacoco/test.exec
# The report task reads demo-app/build/jacoco/{test,isolatedJvmTest}.exec
# (and demo-reactive-app's) when present.
shell: bash
working-directory: libs/vespera-bridge
Expand All @@ -968,6 +968,7 @@ jobs:
name: jni-e2e-${{ matrix.os }}-jdk${{ matrix.java }}-test-results
path: |
examples/rust-jni-demo/java/demo-app/build/test-results/test/*.xml
examples/rust-jni-demo/java/demo-app/build/test-results/isolatedJvmTest/*.xml
examples/rust-jni-demo/java/demo-reactive-app/build/test-results/test/*.xml
libs/vespera-bridge/build/test-results/test/*.xml
libs/vespera-bridge-gradle-plugin/build/test-results/test/*.xml
Expand Down
10 changes: 7 additions & 3 deletions crates/vespera_jni/src/jni_impl_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,13 @@ use crate::streaming_closures::{CompleteAttempt, complete_future, complete_futur
/// group `async_completion_ab`: ~1.5 µs inline vs ~24.5 µs hand-off), so the
/// worker-thread completion is kept and this contract is documented instead —
/// matching how Netty / async HTTP clients complete futures from their I/O
/// threads. The autoconfigured Spring proxy never selects `ASYNC` (its
/// `SmartDispatchModeResolver` uses DIRECT / SYNC / streaming), so this path is
/// opt-in for callers doing their own `CompletableFuture` composition.
/// threads. Java callers that reach this symbol: the WebFlux proxy
/// (`VesperaReactiveProxyHandler`, every buffered request), `VesperaClient`'s
/// `sendAsync()`, SYNC on a virtual thread with
/// `-Dvespera.virtualThread.pollFirst=false` (`ParkingDispatch`), a servlet
/// proxy whose resolver picks `ASYNC`, and callers doing their own
/// `CompletableFuture` composition (the servlet proxy's default
/// `SmartDispatchModeResolver` never selects `ASYNC`).
///
/// Cancellation: the spawned dispatch is registered with
/// [`vespera_inprocess::cancellable`] under the returned token. The Java
Expand Down
2 changes: 1 addition & 1 deletion examples/rust-jni-demo/java/demo-app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
// Records execution data for the vespera-bridge classes this suite drives
// through a REAL loaded cdylib — the JNI wrappers and the Spring proxy that
// the bridge's own unit tests cannot reach. libs/vespera-bridge merges this
// `build/jacoco/test.exec` into its report.
// `build/jacoco/test.exec` and `isolatedJvmTest.exec` into its report.
jacoco
id("org.springframework.boot") version "4.1.2"
id("io.spring.dependency-management") version "1.1.7"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ void rejectedPooledFallbackStreamsOnceAndIsNotRememberedAsAnOverflow(boolean ret

MockHttpServletResponse response = new MockHttpServletResponse();
controller.dispatchDirectMode(
response, null, "GET", path, "", sink -> {}, VesperaWireCodec.EMPTY_BODY, Boolean.TRUE);
response, null, "GET", path, "", sink -> {}, VesperaWireCodec.EMPTY_BODY, Boolean.TRUE, null);

assertEquals(200, response.getStatus());
assertEquals("x".repeat(16), response.getContentAsString(StandardCharsets.UTF_8));
Expand All @@ -101,7 +101,7 @@ void realOverflowStreamsFromTheStashAndTheRouteAvoidsDirectNextTime() throws Exc

MockHttpServletResponse overflow = new MockHttpServletResponse();
controller.dispatchDirectMode(
overflow, null, "GET", path, "", sink -> {}, VesperaWireCodec.EMPTY_BODY, Boolean.FALSE);
overflow, null, "GET", path, "", sink -> {}, VesperaWireCodec.EMPTY_BODY, Boolean.FALSE, null);

assertEquals(200, overflow.getStatus());
assertEquals(BEYOND_POOL_CAP, overflow.getContentAsByteArray().length);
Expand Down
6 changes: 3 additions & 3 deletions libs/vespera-bridge-gradle-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ Gradle plugin `kr.devfive.vespera-bridge` (Maven Central / Plugin Portal: `kr.de
|---|---|---|
| `crateName` | — (required) | Cargo package (`my-app`) or its underscore form; selects the library file name |
| `packageName` / `libName` | derived | `cargo build -p` spec / custom `[lib] name` |
| `cargoRoot`, `targetDir`, `cargoProfile` | `<cargoRoot>/target`, `release` | where the cdylib is read from |
| `cargoRoot`, `targetDir`, `cargoProfile` | `<cargoRoot>/target`, `release` | where the cdylib is read from (leave both locations unset to bundle only `extraNativeLibs`) |
| `autoBuildCargo` | `false` | run `cargoBuild` before `bundleNativeLib` |
| `targets` | empty | extra cross builds: platform → Rust triple |
| `extraNativeLibs` | unset | prebuilt `native/<platform>/<lib>` merged into the app jar |
| `bundleNative` | `true` | `false`: `processResources` bundles nothing (the app takes its library from native jars) |
| `extraNativeLibs` | unset | prebuilt `native/<platform>/<lib>` merged into the app jar (alone, without `cargoRoot` / `targetDir`: the only libraries bundled) |
| `bundleNative` | `true` | `false`: `processResources` bundles nothing, `extraNativeLibs` included (the app takes its library from native jars) |
| `nativeJarPlatforms` | empty | platforms to package as classifier jars (`nativeJar<Platform>`, `nativeJars`) |
| `nativeJarsDir` | `extraNativeLibs` | source of the native jars (`native/<platform>/<lib>`) |
| `bridgeVersion` | the bridge release this plugin was built with | `vespera-bridge` dependency version |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ abstract class VesperaBridgeExtension @Inject constructor(objects: ObjectFactory
* `native/<os>-<arch>/<lib>` (for example CI artifacts from other
* platforms). Everything under its `native/` folder is merged into the
* jar next to the locally built libraries; a locally built library wins
* when both provide the same file.
* when both provide the same file. Without [cargoRoot] / [targetDir]
* only these prebuilt libraries are bundled (no local build is
* expected; the build fails when the folder holds none). With
* `bundleNative = false` they are not bundled either.
*/
abstract val extraNativeLibs: DirectoryProperty

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ class VesperaBridgePlugin : Plugin<Project> {
val base = targetBase.orNull
val crate = ext.crateName.orNull
val profile = ext.cargoProfile.get()
// Prebuilt-only jar: extraNativeLibs without cargoRoot /
// targetDir bundles just those libraries, no local build.
val prebuiltDir = if (base == null) ext.extraNativeLibs.orNull?.asFile else null
val expected = if (base == null) {
emptyList()
} else {
Expand All @@ -137,6 +140,10 @@ class VesperaBridgePlugin : Plugin<Project> {
}
t.doLast(object : org.gradle.api.Action<Task> {
override fun execute(@Suppress("UNUSED_PARAMETER") task: Task) {
if (prebuiltDir != null) {
requirePrebuilt(prebuiltDir)
return
}
requireLocation(crate, base)
expected.forEach { lib -> lib.requireExists(crate.orEmpty(), profile) }
}
Expand Down Expand Up @@ -250,6 +257,19 @@ class VesperaBridgePlugin : Plugin<Project> {
}
}

/**
* Prebuilt-only bundling (`extraNativeLibs` without `cargoRoot` /
* `targetDir`): fail when the directory holds no library at all,
* where the `Sync` would be NO-SOURCE and the jar ship none.
*/
fun requirePrebuilt(dir: File) {
require(File(dir, "native").walk().any { it.isFile }) {
"vespera.extraNativeLibs ($dir) holds no native/<platform>/<lib> file and neither " +
"vespera.cargoRoot nor vespera.targetDir is set: the jar would ship no native " +
"library."
}
}

/** The embedded default `bridgeVersion`, or `null` when the resource is missing. */
fun embeddedBridgeVersion(): String? =
VesperaBridgePlugin::class.java.getResourceAsStream(VERSION_RESOURCE)?.use { input ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,28 @@ class VesperaBridgePluginFunctionalTest {
assertTrue(result.output.contains("vespera.targetDir"), result.output)
}

@Test
fun extraNativeLibsWithoutCargoRootBundlesOnlyThePrebuiltLibraries() {
write("prebuilt/native/$extraPlatform/libmy_app.dylib", "prebuilt")
setUpWithoutCargoRoot(""" extraNativeLibs.set(layout.projectDirectory.dir("prebuilt"))""")

val result = run("processResources").build()

assertEquals(TaskOutcome.SUCCESS, result.task(":bundleNativeLib")?.outcome)
assertEquals("prebuilt", resource("$extraPlatform/libmy_app.dylib").readText())
}

@Test
fun emptyExtraNativeLibsWithoutCargoRootFailsTheBuild() {
File(dir, "prebuilt").mkdirs()
setUpWithoutCargoRoot(""" extraNativeLibs.set(layout.projectDirectory.dir("prebuilt"))""")

val result = run("processResources").buildAndFail()

assertTrue(result.output.contains("holds no native/<platform>/<lib> file"), result.output)
assertFalse(File(dir, "build/resources/main/native").exists())
}

@Test
fun hostKeyMappedToMuslStillBundlesTheHostLibrary() {
assumeTrue(host == "linux-x86_64", "needs a glibc linux-x86_64 build host")
Expand Down
6 changes: 3 additions & 3 deletions libs/vespera-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ vespera {
}
```

Each `targets` key is a bridge platform (`<os>-<arch>`, os `windows` / `macos` / `linux`); its library is read from `<targetDir>/<triple>/<profile>/` and bundled under `native/<os>-<arch>/` with that platform's file name (`.dll` / `lib*.dylib` / `lib*.so`). A plain `linux-<arch>` key built for a `*-linux-musl*` triple is bundled under `native/linux-<arch>-musl/` (where the bridge looks first on a musl JVM); `linux-<arch>-musl` keys are accepted too, so one jar can carry both builds, e.g. `targets.put("linux-x86_64-musl", "x86_64-unknown-linux-musl")` next to the glibc host build. A target bundled into the host platform's folder replaces the host build (on an Alpine build host the host platform is `linux-<arch>-musl`); the resolved folder counts, not the key, so `targets.put("linux-x86_64", "x86_64-unknown-linux-musl")` on a glibc `linux-x86_64` host keeps the host build in `native/linux-x86_64/` next to `native/linux-x86_64-musl/`. The cross toolchain/linker must already be installed. `bundleNativeLib` depends on `verifyNativeLibs`, which fails the build with `Native library not found` when an expected library was not built (also when none was; set `vespera.autoBuildCargo = true` or build it first); with `bundleNative = false` neither task runs. Everything under `extraNativeLibs/native/` is merged in; a locally built library wins over a prebuilt one with the same path. `cargoBuild` is never `UP-TO-DATE` (Cargo's own incremental check decides) and declares no file inputs.
Each `targets` key is a bridge platform (`<os>-<arch>`, os `windows` / `macos` / `linux`); its library is read from `<targetDir>/<triple>/<profile>/` and bundled under `native/<os>-<arch>/` with that platform's file name (`.dll` / `lib*.dylib` / `lib*.so`). A plain `linux-<arch>` key built for a `*-linux-musl*` triple is bundled under `native/linux-<arch>-musl/` (where the bridge looks first on a musl JVM); `linux-<arch>-musl` keys are accepted too, so one jar can carry both builds, e.g. `targets.put("linux-x86_64-musl", "x86_64-unknown-linux-musl")` next to the glibc host build. A target bundled into the host platform's folder replaces the host build (on an Alpine build host the host platform is `linux-<arch>-musl`); the resolved folder counts, not the key, so `targets.put("linux-x86_64", "x86_64-unknown-linux-musl")` on a glibc `linux-x86_64` host keeps the host build in `native/linux-x86_64/` next to `native/linux-x86_64-musl/`. The cross toolchain/linker must already be installed. `bundleNativeLib` depends on `verifyNativeLibs`, which fails the build with `Native library not found` when an expected library was not built (also when none was; set `vespera.autoBuildCargo = true` or build it first); with `bundleNative = false` neither task runs. Everything under `extraNativeLibs/native/` is merged in; a locally built library wins over a prebuilt one with the same path. With neither `cargoRoot` nor `targetDir` set, only the `extraNativeLibs` libraries are bundled (no local build is expected; `verifyNativeLibs` fails when that folder holds none); `bundleNative = false` bundles none of them either. `cargoBuild` is never `UP-TO-DATE` (Cargo's own incremental check decides) and declares no file inputs.

#### Per-platform native jars (publish once per platform, consume one)

Expand Down Expand Up @@ -1157,8 +1157,8 @@ runs. Configuration precedence (first hit wins, then cached):
```java
// Configure streaming parameters before init
VesperaBridge.configureStreaming(
131072, // chunkBytes: 128 KiB (clamped to 4 KiB – 8 MiB)
32 // channelCapacity: 32 slots (clamped to 1 – 1024)
131072, // chunkBytes: 128 KiB (must be within 4 KiB – 8 MiB)
32 // channelCapacity: 32 slots (must be within 1 – 1024)
);
VesperaBridge.init("my_rust_lib");
```
Expand Down
5 changes: 5 additions & 0 deletions libs/vespera-bridge/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ tasks.named<Test>("test") {
// match because demo-app resolves this exact build's JAR from mavenLocal.
val demoAppExecutionData = rootProject.layout.projectDirectory
.file("../../examples/rust-jni-demo/java/demo-app/build/jacoco/test.exec")
// demo-app's `isolated-jvm` tests (library-name auto-init, the buffered-response
// cap, ...) run in its `isolatedJvmTest` task, which writes its own file.
val demoAppIsolatedExecutionData = rootProject.layout.projectDirectory
.file("../../examples/rust-jni-demo/java/demo-app/build/jacoco/isolatedJvmTest.exec")
// The WebFlux E2E app (demo-reactive-app) drives VesperaReactiveProxyHandler
// through the same real cdylib.
val demoReactiveAppExecutionData = rootProject.layout.projectDirectory
Expand All @@ -236,6 +240,7 @@ tasks.named<JacocoReport>("jacocoTestReport") {
files(
layout.buildDirectory.file("jacoco/test.exec"),
demoAppExecutionData,
demoAppIsolatedExecutionData,
demoReactiveAppExecutionData,
).filter { it.exists() },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,27 @@

/**
* The proxy's {@link DispatchMode#DIRECT} path: a pooled direct-buffer
* dispatch plus its two overflow fallbacks (a response stashed on the Rust
* side, and a safe-method re-run through response streaming), and the
* adaptive {@link DirectOverflowMemory} that streams known-large routes up
* front.
* dispatch plus its overflow handling, and the adaptive
* {@link DirectOverflowMemory} that streams known-large routes up front.
*
* <p>An overflow normally runs the handler <em>once</em>: the finished
* response is kept on the Rust side (the overflow stash) and either fetched
* into a grown pooled buffer or, beyond the pool cap / for an event stream,
* streamed from the stash. Only when the native side hands back no stash
* entry does the legacy fallback apply: a safe-method re-run through
* response streaming ({@code direct-retry-on-overflow}), which runs the
* handler a second time.
*
* <p>Only <em>safe</em> methods reach {@link #dispatch}: the controller
* downgrades every other method to {@code SYNC} first, because an overflow
* retry re-runs the Rust handler.
* downgrades every other method to {@code SYNC} first, because of that
* legacy re-run. A request served through another path is recorded on the
* {@link ProxyExchange} ({@link DispatchEvent#mode()}).
*/
final class DirectModeDispatcher {

// Adaptive DIRECT-overflow avoidance: routes that overflowed the pooled
// direct buffer once are streamed up front thereafter, removing the
// repeated DIRECT-overflow-then-stream double dispatch a known-large
// repeated overflow (whole response stashed, then streamed) a known-large
// (download) route would otherwise pay on every request. Internal state,
// so it is created directly rather than injected.
private final DirectOverflowMemory overflowMemory = new DirectOverflowMemory();
Expand Down Expand Up @@ -63,7 +70,7 @@ void dispatch(
HttpServletResponse response,
String appName, String method, String path, String query,
VesperaBridge.HeaderSource headers, byte[] body,
Boolean currentThreadIsVirtual) throws IOException {
Boolean currentThreadIsVirtual, ProxyExchange exchange) throws IOException {
boolean retry = shouldRetry(retryOnOverflow, method);
ByteBuffer wireResp;
try {
Expand Down Expand Up @@ -93,6 +100,7 @@ void dispatch(
// no handler ran and no response overflowed, so stream this one
// request (any method — it runs once) without remembering the
// route as an overflowing one.
ProxyExchange.servedAs(exchange, DispatchMode.STREAMING);
streamResponse(response, appName, method, path, query, headers, body);
return;
} catch (VesperaBridge.BufferTooSmallException overflow) {
Expand All @@ -110,6 +118,7 @@ void dispatch(
// Remember this route so the NEXT request to it streams up
// front — recorded only here, where we actually fall back.
overflowMemory.recordOverflow(appName, method, path, query);
ProxyExchange.servedAs(exchange, DispatchMode.STREAMING);
streamResponse(response, appName, method, path, query, headers, body);
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
* those targets directly on subsequent requests.
*
* <p>Without this, a known-large (e.g. download) route routed to
* {@link DispatchMode#DIRECT} pays the DIRECT-overflow-then-stream
* <strong>double dispatch</strong> on <em>every</em> request: the Rust handler
* runs once into the pooled direct buffer (overflows), then again through
* response streaming. After the first overflow this memory downgrades the route
* to {@link DispatchMode#STREAMING} up front, so later requests dispatch once.
* {@link DispatchMode#DIRECT} pays the overflow on <em>every</em> request: the
* handler runs once, but its whole response is first held on the Rust side
* (the overflow stash) and only then streamed to the client &mdash; a full
* buffered copy plus an extra stash round-trip, instead of chunk-by-chunk
* streaming. (With a native library that hands back no stash entry the
* fallback even re-runs the handler through response streaming.) After the
* first overflow this memory downgrades the route to
* {@link DispatchMode#STREAMING} up front.
*
* <p>Thread-safe and bounded. {@link #shouldAvoidDirect} is a single volatile
* read until the first overflow is recorded, so apps that never overflow DIRECT
Expand Down
Loading
Loading