diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a021499..caa1ee6 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -13,6 +13,5 @@ jobs: with: run_clippy: true minimum_coverage: "0" - additional_system_deps: "libjna-java" - cargo_command_env_vars: "PATH=$JAVA_HOME_21_X64/bin:$PATH CLASSPATH=/usr/share/java/jna.jar" + cargo_command_env_vars: "PATH=$JAVA_HOME_25_X64/bin:$PATH JDK_JAVA_OPTIONS=--enable-native-access=ALL-UNNAMED" secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index f55ccb8..7ccf3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.4.0 + +- switched from JNA generated bindings to FFM ones. Benchmarked performance speedup (via upstream benchmark suite) is from 4.2x-426x. + +### Breaking + +- the "major" (pre-release) bump is primarily because the switch from JNA to FFM is so foundational. +- Java 22+ is required (though 21+ could work using preview features). + ## 0.3.1 - remove spinlock where Java checks for Rust future completion (uses a thenCompose based CF chain to prevent unnecessary blocking) diff --git a/Cargo.lock b/Cargo.lock index 4fa0d16..0958697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,7 +925,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1618,6 +1618,8 @@ dependencies = [ "uniffi-example-futures", "uniffi-example-geometry", "uniffi-example-rondpoint", + "uniffi-example-sprites", + "uniffi-example-todolist", "uniffi-fixture-benchmarks", "uniffi-fixture-coverall", "uniffi-fixture-ext-types", @@ -1678,6 +1680,24 @@ dependencies = [ "uniffi", ] +[[package]] +name = "uniffi-example-sprites" +version = "0.22.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +dependencies = [ + "uniffi", +] + +[[package]] +name = "uniffi-example-todolist" +version = "0.22.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +dependencies = [ + "once_cell", + "thiserror", + "uniffi", +] + [[package]] name = "uniffi-fixture-benchmarks" version = "0.22.0" @@ -2161,7 +2181,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3709f3a..78ee1fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uniffi-bindgen-java" -version = "0.3.1" +version = "0.4.0" authors = ["IronCore Labs "] readme = "README.md" license = "MPL-2.0" @@ -14,10 +14,12 @@ edition = "2024" [lib] name = "uniffi_bindgen_java" path = "src/lib.rs" +bench = false [[bin]] name = "uniffi-bindgen-java" path = "src/main.rs" +bench = false [dependencies] anyhow = "1" @@ -48,6 +50,8 @@ uniffi-example-custom-types = { git = "https://github.com/mozilla/uniffi-rs.git" uniffi-example-futures = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } uniffi-example-geometry = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } uniffi-example-rondpoint = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } +uniffi-example-sprites = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } +uniffi-example-todolist = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } uniffi-fixture-benchmarks = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } uniffi-fixture-coverall = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } uniffi-fixture-ext-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } diff --git a/README.md b/README.md index 5624ac2..727addc 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ Generate [UniFFI](https://github.com/mozilla/uniffi-rs) bindings for Java. Official Kotlin bindings already exist, which can be used by any JVM language including Java. The Java specific bindings use Java-native types where possible for a more ergonomic interface, for example the Java bindings use `CompletableFutures` instead of `kotlinx.coroutines`. +Generated bindings use Java's [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/21/core/foreign-function-and-memory-api.html) (Project Panama) instead of JNA. See [benches/](benches/) for performance comparisons with Kotlin, Python, and Swift. + We highly reccommend you use [UniFFI's proc-macro definition](https://mozilla.github.io/uniffi-rs/latest/proc_macro/index.html) instead of UDL where possible. ## Requirements -* Java 20+: `javac`, and `jar` -* The [Java Native Access](https://github.com/java-native-access/jna#download) JAR downloaded and its path added to your `$CLASSPATH` environment variable. +* Java 22+: `javac`, and `jar` +* At runtime, the JVM must be allowed to use the Foreign Function & Memory API. For classpath-based applications, pass `--enable-native-access=ALL-UNNAMED` to `java`. For JPMS modules, use `--enable-native-access=your.module.name`. See [Java's documentation](https://docs.oracle.com/en/java/javase/25/core/restricted-methods.html#GUID-080FE2FA-F96A-4987-B4E1-A9F089D11B54__GUID-70A202F4-46C0-4D4D-8CD0-9D147854F776) for more information. ## Installation @@ -115,7 +117,7 @@ Arguments: ## Integrating Bindings -After generation you'll have an `--out-dir` full of Java files. Package those into a `.jar` using your build tools of choice, and the result can be imported and used as per normal in any Java project with the `JNA` dependency available. +After generation you'll have an `--out-dir` full of Java files. Package those into a `.jar` using your build tools of choice, and the result can be imported and used as per normal in any Java project. The generated code uses the Foreign Function & Memory API (no external dependencies like JNA are required). Any top level functions in the Rust library will be static methods in a class named after the crate. @@ -131,8 +133,7 @@ The generated Java can be configured using a `uniffi.toml` configuration file. | `custom_types` | | A map which controls how custom types are exposed to Java. See the [custom types section of the UniFFI manual](https://mozilla.github.io/uniffi-rs/latest/udl/custom_types.html#custom-types-in-the-bindings-code) | | `external_packages` | | A map of packages to be used for the specified external crates. The key is the Rust crate name, the value is the Java package which will be used referring to types in that crate. See the [external types section of the manual](https://mozilla.github.io/uniffi-rs/latest/udl/ext_types_external.html#kotlin) | | `rename` | | A map to rename types, functions, methods, and their members in the generated Java bindings. See the [renaming section](https://mozilla.github.io/uniffi-rs/latest/renaming.html). | -| `android` | `false` | Used to toggle on Android specific optimizations (warning: not well tested yet) | -| `android_cleaner` | `android` | Use the `android.system.SystemCleaner` instead of `java.lang.ref.Cleaner`. Fallback in both instances is the one shipped with JNA. | +| `android` | `false` | Generate [PanamaPort](https://github.com/vova7878/PanamaPort)-compatible code for Android. Replaces `java.lang.foreign.*` with `com.v7878.foreign.*` and `java.lang.invoke.VarHandle` with `com.v7878.invoke.VarHandle`. Requires PanamaPort `io.github.vova7878.panama:Core` as a runtime dependency and Android API 26+. | | `omit_checksums` | `false` | Whether to omit checking the library checksums as the library is initialized. Changing this will shoot yourself in the foot if you mixup your build pipeline in any way, but might speed up initialization. | ### Example diff --git a/benches/README.md b/benches/README.md index 4aebbe3..0edd359 100644 --- a/benches/README.md +++ b/benches/README.md @@ -4,15 +4,15 @@ Criterion-based benchmarks measuring FFI call overhead for the generated Java bi ## Prerequisites -Rust toolchain, JDK 21+, and JNA on the `CLASSPATH` — all provided by `nix develop`. +Rust toolchain and JDK 21+ — all provided by `nix develop`. ## Running ```bash -cargo bench # full suite -cargo bench -- call-only # filter by name -cargo bench -- --save-baseline before # save a Criterion baseline -cargo bench -- --load-baseline before # compare against a saved baseline +cargo bench # full suite +cargo bench -- call-only # filter by name +cargo bench -- --save-baseline before # save a Criterion baseline +cargo bench -- --load-baseline before # compare against a saved baseline ``` ## How It Works @@ -35,40 +35,42 @@ Criterion runs inside the Rust fixture library. The Java side implements `TestCa HTML reports are written to `target/criterion/`. Raw data persists across runs for regression detection. -### Pre-results +### Comparison: Java FFM vs JNA and upstream languages -A run of the benchmarks (and comparison to upstream) on March 26, 2026 with an M4 Max 2024 Macbook Pro, 36GB memory. There was an upstream error in Python's `nested-data` bench at this time. All calls are in microseconds unless noted. +April 1, 2026 on an M4 Max 2024 MacBook Pro, 36GB memory. Java FFM uses JDK 25 with the Foreign Function & Memory API. Java JNA uses JDK 21. Kotlin, Python, and Swift results are from upstream `uniffi-rs` main. #### Function Calls (foreign code calling Rust) -| Test Case | Java | Kotlin | Python | Swift | -|--------------------|----------|----------|----------|----------| -| call-only | 2.1 | 2.8 | 810 ns | 172 ns | -| primitives | 1.8 | 3.0 | 1.4 | 195 ns | -| strings | 14.7 | 16.8 | 9.2 | 973 ns | -| large-strings | 15.9 | 21.5 | 12.5 | 1.3 | -| records | 14.5 | 16.6 | 18.9 | 2.6 | -| enums | 12.6 | 16.7 | 14.6 | 2.1 | -| vecs | 15.6 | 17.2 | 18.3 | 4.4 | -| hash-maps | 12.3 | 16.9 | 23.4 | 6.3 | -| interfaces | 8.6 | 13.8 | 4.3 | 396 ns | -| trait-interfaces | 9.2 | 12.8 | 4.4 | 461 ns | -| nested-data | 13.5 | 17.9 | --- | 20.6 | -| errors | 4.7 | 7.4 | 3.2 | 642 ns | +| Test Case | Java FFM | Java JNA | Kotlin | Python | Swift | +|------------------|----------|-----------|-----------|-----------|-----------| +| call-only | 4.3 ns | 1.83 us | 2.89 us | 792 ns | 165 ns | +| primitives | 4.5 ns | 1.62 us | 3.27 us | 1.39 us | 191 ns | +| strings | 324 ns | 11.46 us | 15.08 us | 9.06 us | 929 ns | +| large-strings | 3.69 us | 15.63 us | 21.69 us | 12.33 us | 1.28 us | +| records | 371 ns | 11.62 us | 15.29 us | 18.66 us | 2.54 us | +| enums | 334 ns | 14.47 us | 15.41 us | 14.22 us | 2.04 us | +| vecs | 581 ns | 13.14 us | 15.36 us | 18.02 us | 4.55 us | +| hash-maps | 638 ns | 12.91 us | 15.48 us | 23.15 us | 6.39 us | +| interfaces | 530 ns * | 6.07 us | 105.60 us | 4.21 us | 409 ns | +| trait-interfaces | 532 ns * | 11.98 us | 150.70 us | 4.30 us | 477 ns | +| nested-data | 1.90 us | 12.56 us | 15.64 us | 77.09 us | 20.86 us | +| errors | 682 ns | 4.51 us | 6.44 us | 3.20 us | 646 ns | #### Callbacks (Rust calling foreign code) -| Test Case | Java | Kotlin | Python | Swift | -|--------------------|----------|----------|----------|----------| -| call-only | 2.6 | 3.2 | 477 ns | 121 ns | -| primitives | 4.3 | 3.9 | 793 ns | 166 ns | -| strings | 13.5 | 18.0 | 8.0 | 811 ns | -| large-strings | 18.2 | 22.6 | 10.7 | 1.6 | -| records | 16.6 | 17.5 | 14.0 | 2.8 | -| enums | 17.0 | 17.5 | 11.0 | 2.4 | -| vecs | 16.9 | 17.9 | 18.1 | 4.8 | -| hash-maps | 12.7 | 18.1 | 21.4 | 7.1 | -| interfaces | 7.8 | 35.6 | 4.1 | 429 ns | -| trait-interfaces | 11.1 | 27.2 | 4.3 | 528 ns | -| nested-data | 21.8 | 16.8 | --- | 24.1 | -| errors | 10.4 | 8.9 | 4.3 | 566 ns | +| Test Case | Java FFM | Java JNA | Kotlin | Python | Swift | +|------------------|----------|-----------|-----------|-----------|-----------| +| call-only | 55 ns | 1.98 us | 2.76 us | 492 ns | 121 ns | +| primitives | 59 ns | 2.66 us | 3.40 us | 804 ns | 166 ns | +| strings | 348 ns | 13.76 us | 15.69 us | 8.17 us | 811 ns | +| large-strings | 3.84 us | 17.47 us | 20.55 us | 10.77 us | 1.60 us | +| records | 449 ns | 11.55 us | 15.95 us | 14.26 us | 2.86 us | +| enums | 406 ns | 11.17 us | 14.93 us | 11.04 us | 2.47 us | +| vecs | 593 ns | 11.11 us | 15.02 us | 17.95 us | 5.01 us | +| hash-maps | 723 ns | 12.36 us | 15.72 us | 21.39 us | 7.16 us | +| interfaces | 964 ns * | 7.44 us | 408.65 us | 4.19 us | 448 ns | +| trait-interfaces | 790 ns * | 7.49 us | 361.69 us | 4.33 us | 552 ns | +| nested-data | 1.93 us | 19.77 us | 16.29 us | 67.20 us | 24.54 us | +| errors | 638 ns | 6.37 us | 7.98 us | 4.35 us | 574 ns | + +\* Interface benchmarks have high variance due to GC pauses; min values are ~250-470ns. diff --git a/benches/bench_compare.py b/benches/bench_compare.py new file mode 100755 index 0000000..f24eb38 --- /dev/null +++ b/benches/bench_compare.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Parse Criterion benchmark output files and produce comparison tables. + +Usage: + python3 bench_compare.py "Column Title" path/to/bench.txt ["Title2" path2.txt ...] + python3 bench_compare.py --speedup "Faster" file1.txt "Slower" file2.txt + nix shell nixpkgs#python3 -c python3 bench_compare.py ... + +The language prefix (e.g. "java-") is extracted from bench names and appended +to the column title automatically. + +With --speedup, exactly two columns are expected and the output is a speedup +table showing how much faster the first column is vs the second (ratio > 1 +means first is faster, < 1 means second is faster). +""" + +import re +import sys +from collections import OrderedDict + +UNIT_TO_NS = {'ns': 1, 'µs': 1e3, 'us': 1e3, 'ms': 1e6, 's': 1e9} + + +def parse_bench_file(path): + """Parse a Criterion benchmark output file. + + Returns (language, results) where results maps + (category, bench_name) -> nanoseconds as float. + """ + results = {} + current_bench = None + language = None + + name_re = re.compile(r'^(\S+?)/(\w+?)-(\S+?)(?:\s+time:|\s*$)') + time_re = re.compile(r'time:\s+\[[\d.]+ \S+ ([\d.]+) (ns|µs|us|ms|s) [\d.]+ \S+\]') + + with open(path) as f: + for line in f: + stripped = line.strip() + + name_m = name_re.match(stripped) + if name_m: + language = language or name_m.group(2) + current_bench = (name_m.group(1), name_m.group(3)) + + time_m = time_re.search(line) + if time_m and current_bench: + ns = float(time_m.group(1)) * UNIT_TO_NS[time_m.group(2)] + results[current_bench] = ns + current_bench = None + + return language, results + + +def format_time(ns): + if ns < 1000: + return f"{ns:.2f} ns" if ns >= 10 else f"{ns:.1f} ns" + elif ns < 1e6: + return f"{ns / 1e3:.2f} us" + elif ns < 1e9: + return f"{ns / 1e6:.2f} ms" + else: + return f"{ns / 1e9:.2f} s" + + +def format_speedup(ratio): + if ratio >= 10: + return f"{ratio:.0f}x" + else: + return f"{ratio:.1f}x" + + +def collect_categories(all_results): + categories = OrderedDict() + for results in all_results: + for cat, bench in results: + if cat not in categories: + categories[cat] = OrderedDict() + if bench not in categories[cat]: + categories[cat][bench] = True + return categories + + +def print_comparison(columns, all_results): + categories = collect_categories(all_results) + + for cat, benches in categories.items(): + print(f"### {cat}\n") + header = "Test Case | " + " | ".join(columns) + sep = "-- | " + " | ".join("--" for _ in columns) + print(header) + print(sep) + + for bench in benches: + values = [] + for results in all_results: + ns = results.get((cat, bench)) + values.append(format_time(ns) if ns is not None else "") + print(f"{bench} | " + " | ".join(values)) + + print() + + +def print_speedup(columns, all_results): + categories = collect_categories(all_results) + a_results, b_results = all_results + title = f"{columns[0]} vs {columns[1]} Speedup" + + cat_names = list(categories.keys()) + + print(f"### {title}\n") + header = "Test Case | " + " | ".join(f"{cat} Speedup" for cat in cat_names) + sep = "-- | " + " | ".join("--" for _ in cat_names) + print(header) + print(sep) + + all_benches = OrderedDict() + for benches in categories.values(): + for b in benches: + all_benches[b] = True + + for bench in all_benches: + values = [] + for cat in cat_names: + a_ns = a_results.get((cat, bench)) + b_ns = b_results.get((cat, bench)) + if a_ns is not None and b_ns is not None and a_ns > 0: + values.append(format_speedup(b_ns / a_ns)) + else: + values.append("") + print(f"{bench} | " + " | ".join(values)) + + print() + + +def main(): + args = sys.argv[1:] + + speedup = False + if "--speedup" in args: + speedup = True + args.remove("--speedup") + + if len(args) < 2 or len(args) % 2 != 0: + print("Usage: bench_compare.py [--speedup] \"Title1\" file1.txt [\"Title2\" file2.txt ...]", + file=sys.stderr) + sys.exit(1) + + if speedup and len(args) != 4: + print("--speedup requires exactly two title/file pairs", file=sys.stderr) + sys.exit(1) + + columns = [] + all_results = [] + for i in range(0, len(args), 2): + title = args[i] + path = args[i + 1] + lang, results = parse_bench_file(path) + if lang: + title = f"{title} ({lang.capitalize()})" + columns.append(title) + all_results.append(results) + + if speedup: + print_speedup(columns, all_results) + else: + print_comparison(columns, all_results) + + +if __name__ == "__main__": + main() diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index 05f3963..de9e7e1 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -16,7 +16,6 @@ use anyhow::{Context, Result, bail}; use camino::Utf8PathBuf; use std::env; -use std::env::consts::ARCH; use std::fs; use std::path::PathBuf; use std::process::Command; @@ -58,20 +57,31 @@ fn main() -> Result<()> { }, )?; - // Set up JNA native library path: copy cdylib into {os}-{arch}/ inside a staging dir - let jna_resource_folder = if cdylib_path.extension().unwrap() == "dylib" { - format!("darwin-{}", ARCH).replace('_', "-") - } else { - format!("linux-{}", ARCH).replace('_', "-") - }; - let staging_dir = tmp_dir.join("staging"); - let native_resource_dir = staging_dir.join(&jna_resource_folder); - fs::create_dir_all(&native_resource_dir)?; - let cdylib_dest = native_resource_dir.join(cdylib_path.file_name().unwrap()); + // Copy cdylib and create symlink for System.loadLibrary + let native_lib_dir = tmp_dir.join("native"); + fs::create_dir_all(&native_lib_dir)?; + let cdylib_filename = cdylib_path.file_name().unwrap(); + let cdylib_dest = native_lib_dir.join(cdylib_filename); fs::copy(cdylib_path.as_std_path(), &cdylib_dest)?; + let extension = cdylib_path.extension().unwrap(); + let lib_base_name = cdylib_filename + .strip_prefix("lib") + .unwrap_or(cdylib_filename) + .split('-') + .next() + .unwrap_or(cdylib_filename); + let expected_lib_name = format!("lib{}.{}", lib_base_name, extension); + let symlink_path = native_lib_dir.join(&expected_lib_name); + if !symlink_path.exists() { + std::os::unix::fs::symlink(cdylib_dest.file_name().unwrap(), &symlink_path)?; + } + // Compile generated bindings into a jar println!("Compiling Java bindings..."); + let staging_dir = tmp_dir.join("staging"); + fs::create_dir_all(&staging_dir)?; + let java_sources: Vec<_> = glob::glob(&format!("{}/**/*.java", out_dir))? .flatten() .map(|p| p.to_string_lossy().to_string()) @@ -126,23 +136,30 @@ fn main() -> Result<()> { bail!("javac failed when compiling the benchmark runner") } - // Run the benchmark. The Java process calls Benchmarks.runBenchmarks() which - // drives Criterion internally. We pass CLI args through via "--" so that the - // fixture's Args::parse_for_run_benchmarks() can pick them up. + // Run the benchmark println!("Running benchmarks..."); let run_classpath = calc_classpath(vec![ jar_file.to_string_lossy().to_string(), tmp_dir.to_string_lossy().to_string(), ]); - // Collect args after "--" to pass through to the Java process - let pass_through_args: Vec = env::args().skip_while(|a| a != "--").collect(); + // Forward user args (filters, --save-baseline, etc.) to the Java process. + // cargo bench passes args as: [binary, filter..., --bench] + // We prepend "--" so parse_for_run_benchmarks() can find the separator. + let user_args: Vec = env::args().skip(1).filter(|a| a != "--bench").collect(); let mut cmd = Command::new("java"); - cmd.arg("-classpath") + cmd.arg("-Xmx2g") + .arg("--enable-native-access=ALL-UNNAMED") + .arg(format!("-Djava.library.path={}", native_lib_dir.display())) + .arg("-classpath") .arg(&run_classpath) .arg("RunBenchmarks") - .args(&pass_through_args); + .arg("--") + // parse_for_run_benchmarks() uses clap's parse_from() which expects argv[0] + // to be a program name. Insert a dummy so the real args aren't consumed as argv[0]. + .arg("java-bench") + .args(&user_args); let status = cmd .spawn() diff --git a/flake.lock b/flake.lock index 8eec82e..fa21354 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773821835, - "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "lastModified": 1774709303, + "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", "type": "github" }, "original": { @@ -62,11 +62,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1773975983, - "narHash": "sha256-zrRVwdfhDdohANqEhzY/ydeza6EXEi8AG6cyMRNYT9Q=", + "lastModified": 1774926780, + "narHash": "sha256-JMdDYn0F+swYBILlpCeHDbCSyzqkeSGNxZ/Q5J584jM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "cc80954a95f6f356c303ed9f08d0b63ca86216ac", + "rev": "962a0934d0e32f42d1b5e49186f9595f9b178d2d", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 2bfdfad..b971380 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,8 @@ # nix develop devShell = pkgs.mkShell { buildInputs = with pkgs; - [ rusttoolchain pkg-config openjdk21 jna ]; + [ rusttoolchain pkg-config jdk25 ] + ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ pkgs.apple-sdk_15 ]; }; }); diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index b3f587e..8398c93 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -161,8 +161,6 @@ pub struct Config { pub(super) external_packages: HashMap, #[serde(default)] android: bool, - #[serde(default)] - android_cleaner: Option, /// Renames for types, fields, methods, variants, and arguments. /// Uses dot notation: "OldRecord" = "NewRecord", "OldRecord.field" = "new_field" #[serde(default)] @@ -170,13 +168,17 @@ pub struct Config { } impl Config { - pub(crate) fn android_cleaner(&self) -> bool { - self.android_cleaner.unwrap_or(self.android) - } - pub fn omit_checksums(&self) -> bool { self.omit_checksums } + + /// Whether to generate PanamaPort imports for Android compatibility. + /// When true, post-processes generated code to replace `java.lang.foreign.*` with + /// PanamaPort's `com.v7878.foreign.*` and `java.lang.invoke.VarHandle` with + /// `com.v7878.invoke.VarHandle`. + pub fn android(&self) -> bool { + self.android + } } impl Config { @@ -246,9 +248,21 @@ impl CustomTypeConfig { // Generate Java bindings for the given ComponentInterface, as a string. pub fn generate_bindings(config: &Config, ci: &ComponentInterface) -> Result { - JavaWrapper::new(config.clone(), ci) + let output = JavaWrapper::new(config.clone(), ci) .render() - .context("failed to render java bindings") + .context("failed to render java bindings")?; + + if config.android() { + // PanamaPort provides the FFM API under a different package prefix. + // The API surface is identical so a global string replacement is sufficient. + // `java.lang.invoke.MethodHandle/MethodHandles/MethodType` are available on + // Android API 26+ and are intentionally left unchanged. + Ok(output + .replace("java.lang.foreign.", "com.v7878.foreign.") + .replace("java.lang.invoke.VarHandle", "com.v7878.invoke.VarHandle")) + } else { + Ok(output) + } } #[derive(Template)] @@ -385,7 +399,7 @@ impl JavaCodeOracle { fixup_keyword(self.var_name_raw(nm)) } - /// `var_name` without the reserved word alteration. Useful for using in `@Structure.FieldOrder`. + /// `var_name` without the reserved word alteration. Useful for struct field names. pub fn var_name_raw(&self, nm: &str) -> String { nm.to_string().to_lower_camel_case() } @@ -410,169 +424,215 @@ impl JavaCodeOracle { format!("Uniffi{}", nm.to_upper_camel_case()) } - fn ffi_type_label_by_value( + /// FFI type label for use in method signatures and MethodHandle wrapper methods. + /// In FFM, primitives stay as primitives, everything else is MemorySegment + /// (except Handle/RustArcPtr which are long). + fn ffi_type_label( &self, ffi_type: &FfiType, - prefer_primitive: bool, - config: &Config, - ci: &ComponentInterface, + _config: &Config, + _ci: &ComponentInterface, ) -> String { match ffi_type { - FfiType::RustBuffer(_) => { - format!("{}.ByValue", self.ffi_type_label(ffi_type, config, ci)) + // Note that unsigned values in Java don't have true native support. Signed primitives + // can contain unsigned values and there are methods like `Integer.compareUnsigned` + // that respect the unsigned value, but knowledge outside the type system is required. + // TODO(java): improve callers knowledge of what contains an unsigned value + FfiType::Int8 | FfiType::UInt8 => "byte".to_string(), + FfiType::Int16 | FfiType::UInt16 => "short".to_string(), + FfiType::Int32 | FfiType::UInt32 => "int".to_string(), + FfiType::Int64 | FfiType::UInt64 => "long".to_string(), + FfiType::Float32 => "float".to_string(), + FfiType::Float64 => "double".to_string(), + FfiType::Handle => "long".to_string(), + // In FFM, all struct types, buffers, pointers, and callbacks are MemorySegment + FfiType::RustBuffer(_) + | FfiType::RustCallStatus + | FfiType::ForeignBytes + | FfiType::Callback(_) + | FfiType::Struct(_) + | FfiType::VoidPointer + | FfiType::Reference(_) + | FfiType::MutReference(_) => "java.lang.foreign.MemorySegment".to_string(), + } + } + + /// FFI type label using boxed types for use in generic contexts (e.g. FfiConverter). + /// Java generics don't accept primitives. + fn ffi_type_label_boxed(&self, ffi_type: &FfiType) -> String { + match ffi_type { + FfiType::Int8 | FfiType::UInt8 => "java.lang.Byte".to_string(), + FfiType::Int16 | FfiType::UInt16 => "java.lang.Short".to_string(), + FfiType::Int32 | FfiType::UInt32 => "java.lang.Integer".to_string(), + FfiType::Int64 | FfiType::UInt64 => "java.lang.Long".to_string(), + FfiType::Float32 => "java.lang.Float".to_string(), + FfiType::Float64 => "java.lang.Double".to_string(), + FfiType::Handle => "java.lang.Long".to_string(), + _ => "java.lang.foreign.MemorySegment".to_string(), + } + } + + /// Maps FfiType to ValueLayout constant for FunctionDescriptor construction + fn ffi_value_layout(&self, ffi_type: &FfiType) -> String { + match ffi_type { + FfiType::Int8 | FfiType::UInt8 => "java.lang.foreign.ValueLayout.JAVA_BYTE".to_string(), + FfiType::Int16 | FfiType::UInt16 => { + "java.lang.foreign.ValueLayout.JAVA_SHORT".to_string() + } + FfiType::Int32 | FfiType::UInt32 => { + "java.lang.foreign.ValueLayout.JAVA_INT".to_string() } - FfiType::Struct(name) => format!("{}.UniffiByValue", self.ffi_struct_name(name)), - _ if prefer_primitive => self.ffi_type_primitive(ffi_type, config, ci), - _ => self.ffi_type_label(ffi_type, config, ci), + FfiType::Int64 | FfiType::UInt64 => { + "java.lang.foreign.ValueLayout.JAVA_LONG".to_string() + } + FfiType::Float32 => "java.lang.foreign.ValueLayout.JAVA_FLOAT".to_string(), + FfiType::Float64 => "java.lang.foreign.ValueLayout.JAVA_DOUBLE".to_string(), + FfiType::Handle => "java.lang.foreign.ValueLayout.JAVA_LONG".to_string(), + FfiType::RustBuffer(_) => "RustBuffer.LAYOUT".to_string(), + // RustCallStatus is passed as a pointer in the C ABI + FfiType::RustCallStatus => "java.lang.foreign.ValueLayout.ADDRESS".to_string(), + FfiType::ForeignBytes => "ForeignBytes.LAYOUT".to_string(), + FfiType::Struct(name) => format!("{}.LAYOUT", self.ffi_struct_name(name)), + FfiType::Callback(_) + | FfiType::VoidPointer + | FfiType::Reference(_) + | FfiType::MutReference(_) => "java.lang.foreign.ValueLayout.ADDRESS".to_string(), } } - /// FFI type name to use inside structs - /// - /// The main requirement here is that all types must have default values or else the struct - /// won't work in some JNA contexts. - fn ffi_type_label_for_ffi_struct( - &self, - ffi_type: &FfiType, - config: &Config, - ci: &ComponentInterface, - ) -> String { + /// Returns the alignment requirement (in bytes) for an FFI type + fn ffi_type_alignment(&self, ffi_type: &FfiType) -> usize { match ffi_type { - // Make callbacks function pointers nullable. This matches the semantics of a C - // function pointer better and allows for `null` as a default value. - // Everything is nullable in Java by default. - FfiType::Callback(name) => self.ffi_callback_name(name).to_string(), - _ => self.ffi_type_label_by_value(ffi_type, true, config, ci), + FfiType::Int8 | FfiType::UInt8 => 1, + FfiType::Int16 | FfiType::UInt16 => 2, + FfiType::Int32 | FfiType::UInt32 | FfiType::Float32 => 4, + FfiType::Int64 | FfiType::UInt64 | FfiType::Float64 | FfiType::Handle => 8, + // Pointers are pointer-aligned (8 bytes on 64-bit) + FfiType::Callback(_) + | FfiType::VoidPointer + | FfiType::Reference(_) + | FfiType::MutReference(_) => 8, + // Structs: alignment is the max alignment of their fields. + // RustBuffer has JAVA_LONG (8) as first field → 8-byte aligned + // RustCallStatus starts with JAVA_BYTE but contains RustBuffer → 8-byte aligned + // ForeignBytes starts with JAVA_INT → but contains ADDRESS → 8-byte aligned + FfiType::RustBuffer(_) + | FfiType::RustCallStatus + | FfiType::ForeignBytes + | FfiType::Struct(_) => 8, } } - /// Default values for FFI - /// - /// This is used to: - /// - Set a default return value for error results - /// - Set a default for structs, which JNA sometimes requires - fn ffi_default_value(&self, ffi_type: &FfiType) -> String { + /// Returns the size (in bytes) for an FFI type + fn ffi_type_size(&self, ffi_type: &FfiType) -> usize { match ffi_type { - FfiType::UInt8 | FfiType::Int8 => "(byte)0".to_owned(), - FfiType::UInt16 | FfiType::Int16 => "(short)0".to_owned(), - FfiType::UInt32 | FfiType::Int32 => "0".to_owned(), - FfiType::UInt64 | FfiType::Int64 => "0L".to_owned(), - FfiType::Float32 => "0.0f".to_owned(), - FfiType::Float64 => "0.0".to_owned(), - FfiType::Handle => "0L".to_owned(), - FfiType::RustBuffer(_) => "new RustBuffer.ByValue()".to_owned(), - FfiType::Callback(_) => "null".to_owned(), - FfiType::RustCallStatus => "new UniffiRustCallStatus.ByValue()".to_owned(), - _ => unimplemented!("ffi_default_value: {ffi_type:?}"), + FfiType::Int8 | FfiType::UInt8 => 1, + FfiType::Int16 | FfiType::UInt16 => 2, + FfiType::Int32 | FfiType::UInt32 | FfiType::Float32 => 4, + FfiType::Int64 | FfiType::UInt64 | FfiType::Float64 | FfiType::Handle => 8, + FfiType::Callback(_) + | FfiType::VoidPointer + | FfiType::Reference(_) + | FfiType::MutReference(_) => 8, + // Struct sizes: these are hardcoded based on their layouts + FfiType::RustBuffer(_) => 24, // long + long + pointer + FfiType::RustCallStatus => 32, // byte + 7 pad + RustBuffer(24) + FfiType::ForeignBytes => 16, // int + 4 pad + pointer + FfiType::Struct(_) => 0, // unknown at codegen time, handled by LAYOUT } } - fn ffi_type_label_by_reference( - &self, - ffi_type: &FfiType, - config: &Config, - ci: &ComponentInterface, - ) -> String { + /// Maps FfiType to layout for use as a struct field in StructLayout definitions. + /// Unlike ffi_value_layout (for FunctionDescriptors), embedded structs use their + /// full LAYOUT here rather than ADDRESS. + fn ffi_struct_field_layout(&self, ffi_type: &FfiType) -> String { + match ffi_type { + FfiType::RustBuffer(_) => "RustBuffer.LAYOUT".to_string(), + FfiType::RustCallStatus => "UniffiRustCallStatus.LAYOUT".to_string(), + FfiType::ForeignBytes => "ForeignBytes.LAYOUT".to_string(), + FfiType::Struct(name) => format!("{}.LAYOUT", self.ffi_struct_name(name)), + _ => self.ffi_value_layout(ffi_type), + } + } + + /// Maps FfiType to UNALIGNED ValueLayout for struct field access + fn ffi_value_layout_unaligned(&self, ffi_type: &FfiType) -> String { match ffi_type { - FfiType::Int32 | FfiType::UInt32 => "IntByReference".to_string(), - FfiType::Int8 - | FfiType::UInt8 - | FfiType::Int16 - | FfiType::UInt16 - | FfiType::Int64 - | FfiType::UInt64 - | FfiType::Float32 - | FfiType::Float64 => { - format!("{}ByReference", self.ffi_type_label(ffi_type, config, ci)) + FfiType::Int8 | FfiType::UInt8 => "java.lang.foreign.ValueLayout.JAVA_BYTE".to_string(), // byte has no alignment + FfiType::Int16 | FfiType::UInt16 => { + "java.lang.foreign.ValueLayout.JAVA_SHORT_UNALIGNED".to_string() + } + FfiType::Int32 | FfiType::UInt32 => { + "java.lang.foreign.ValueLayout.JAVA_INT_UNALIGNED".to_string() } - FfiType::Handle => "LongByReference".to_owned(), - // JNA structs default to ByReference - FfiType::RustBuffer(_) | FfiType::Struct(_) => { - self.ffi_type_label(ffi_type, config, ci) + FfiType::Int64 | FfiType::UInt64 => { + "java.lang.foreign.ValueLayout.JAVA_LONG_UNALIGNED".to_string() } - _ => panic!("{ffi_type:?} by reference is not implemented"), + FfiType::Float32 => "java.lang.foreign.ValueLayout.JAVA_FLOAT_UNALIGNED".to_string(), + FfiType::Float64 => "java.lang.foreign.ValueLayout.JAVA_DOUBLE_UNALIGNED".to_string(), + FfiType::Handle => "java.lang.foreign.ValueLayout.JAVA_LONG_UNALIGNED".to_string(), + FfiType::Callback(_) + | FfiType::VoidPointer + | FfiType::Reference(_) + | FfiType::MutReference(_) => { + "java.lang.foreign.ValueLayout.ADDRESS_UNALIGNED".to_string() + } + // Structs use slice-based access, not ValueLayout access + _ => self.ffi_value_layout(ffi_type), } } - fn ffi_type_label( - &self, - ffi_type: &FfiType, - config: &Config, - ci: &ComponentInterface, - ) -> String { + /// Cast prefix needed for invokeExact() return values + fn ffi_invoke_exact_cast(&self, ffi_type: &FfiType) -> String { match ffi_type { - // Note that unsigned values in Java don't have true native support. Signed primitives - // can contain unsigned values and there are methods like `Integer.compareUnsigned` - // that respect the unsigned value, but knowledge outside the type system is required. - // TODO(java): improve callers knowledge of what contains an unsigned value - FfiType::Int8 | FfiType::UInt8 => "Byte".to_string(), - FfiType::Int16 | FfiType::UInt16 => "Short".to_string(), - FfiType::Int32 | FfiType::UInt32 => "Integer".to_string(), - FfiType::Int64 | FfiType::UInt64 => "Long".to_string(), - FfiType::Float32 => "Float".to_string(), - FfiType::Float64 => "Double".to_string(), - FfiType::Handle => "Long".to_string(), - FfiType::RustBuffer(maybe_external) => match maybe_external { - Some(external_meta) if external_meta.crate_name() != ci.crate_name() => { - format!( - "{}.RustBuffer", - config.external_type_package_name( - &external_meta.module_path, - &external_meta.name - ) - ) - } - _ => "RustBuffer".to_string(), - }, - FfiType::RustCallStatus => "UniffiRustCallStatus.ByValue".to_string(), - FfiType::ForeignBytes => "ForeignBytes.ByValue".to_string(), - FfiType::Callback(name) => self.ffi_callback_name(name), - FfiType::Struct(name) => self.ffi_struct_name(name), - FfiType::Reference(inner) | FfiType::MutReference(inner) => { - self.ffi_type_label_by_reference(inner, config, ci) - } - FfiType::VoidPointer => "Pointer".to_string(), + FfiType::Int8 | FfiType::UInt8 => "(byte) ".to_string(), + FfiType::Int16 | FfiType::UInt16 => "(short) ".to_string(), + FfiType::Int32 | FfiType::UInt32 => "(int) ".to_string(), + FfiType::Int64 | FfiType::UInt64 => "(long) ".to_string(), + FfiType::Float32 => "(float) ".to_string(), + FfiType::Float64 => "(double) ".to_string(), + FfiType::Handle => "(long) ".to_string(), + FfiType::RustBuffer(_) + | FfiType::RustCallStatus + | FfiType::ForeignBytes + | FfiType::Callback(_) + | FfiType::Struct(_) + | FfiType::VoidPointer + | FfiType::Reference(_) + | FfiType::MutReference(_) => "(java.lang.foreign.MemorySegment) ".to_string(), } } - /// Generate primitive types where possible. Useful where we don't need or can't have boxed versions (ie structs). - fn ffi_type_primitive( - &self, - ffi_type: &FfiType, - config: &Config, - ci: &ComponentInterface, - ) -> String { + /// Returns true if the FFI type is a struct that requires a SegmentAllocator + /// as the first argument to invokeExact() for downcall handles + fn ffi_type_is_struct(&self, ffi_type: &FfiType) -> bool { + // RustCallStatus is passed as a pointer, not by value, so it doesn't need SegmentAllocator + matches!( + ffi_type, + FfiType::RustBuffer(_) | FfiType::ForeignBytes | FfiType::Struct(_) + ) + } + + /// Returns true if this FFI type is an embedded struct inside another struct + /// (uses slice-based access rather than primitive get/set) + fn ffi_type_is_embedded_struct(&self, ffi_type: &FfiType) -> bool { + matches!( + ffi_type, + FfiType::RustBuffer(_) + | FfiType::RustCallStatus + | FfiType::ForeignBytes + | FfiType::Struct(_) + ) + } + + /// Get the struct class name for an FfiType::Struct + fn ffi_struct_type_name(&self, ffi_type: &FfiType) -> String { match ffi_type { - // Note that unsigned integers in Java are currently experimental, but java.nio.ByteBuffer does not - // support them yet. Thus, we use the signed variants to represent both signed and unsigned - // types from the component API. - FfiType::Int8 | FfiType::UInt8 => "byte".to_string(), - FfiType::Int16 | FfiType::UInt16 => "short".to_string(), - FfiType::Int32 | FfiType::UInt32 => "int".to_string(), - FfiType::Int64 | FfiType::UInt64 => "long".to_string(), - FfiType::Float32 => "float".to_string(), - FfiType::Float64 => "double".to_string(), - FfiType::Handle => "long".to_string(), - FfiType::RustBuffer(maybe_external) => match maybe_external { - Some(external_meta) if external_meta.crate_name() != ci.crate_name() => { - format!( - "{}.RustBuffer", - config.external_type_package_name( - &external_meta.module_path, - &external_meta.name - ) - ) - } - _ => "RustBuffer".to_string(), - }, - FfiType::RustCallStatus => "UniffiRustCallStatus.ByValue".to_string(), - FfiType::ForeignBytes => "ForeignBytes.ByValue".to_string(), - FfiType::Callback(name) => self.ffi_callback_name(name), FfiType::Struct(name) => self.ffi_struct_name(name), - FfiType::Reference(inner) | FfiType::MutReference(inner) => { - self.ffi_type_label_by_reference(inner, config, ci) - } - FfiType::VoidPointer => "Pointer".to_string(), + FfiType::RustBuffer(_) => "RustBuffer".to_string(), + FfiType::RustCallStatus => "UniffiRustCallStatus".to_string(), + FfiType::ForeignBytes => "ForeignBytes".to_string(), + _ => panic!("ffi_struct_type_name called on non-struct type: {ffi_type:?}"), } } @@ -705,7 +765,6 @@ impl AsCodeType for &'_ uniffi_bindgen::interface::CallbackInterface { mod filters { #![allow(unused_variables)] use super::*; - use uniffi_bindgen::interface::ffi::ExternalFfiMetadata; use uniffi_meta::AsType; // Askama 0.14 passes a Values parameter to all filters. We use `_v` to accept but ignore it. @@ -927,39 +986,146 @@ mod filters { } } - pub fn ffi_type_name_by_value( + /// FFI type name (primitive for scalars, MemorySegment for everything else) + pub fn ffi_type_name( type_: &FfiType, _v: &dyn askama::Values, config: &Config, ci: &ComponentInterface, ) -> Result { - Ok(JavaCodeOracle.ffi_type_label_by_value(type_, false, config, ci)) + Ok(JavaCodeOracle.ffi_type_label(type_, config, ci)) } - pub fn ffi_type_name_for_ffi_struct( + /// Returns the primitive call suffix (e.g. "Long", "Int") for primitive-specialized + /// uniffiRustCall variants. Returns empty string for types where the high-level Java + /// primitive doesn't match the FFI primitive (e.g. Boolean→byte) or non-primitive types. + pub fn primitive_call_suffix( + as_ct: &impl AsCodeType, + _v: &dyn askama::Values, + ) -> Result { + // Only bypass FfiConverter when the Java primitive matches the FFI primitive. + // Boolean is excluded because it's `boolean` in Java but `byte` at the FFI level. + Ok( + match as_ct.as_codetype().type_label_primitive().as_deref() { + Some("byte") => "Byte", + Some("short") => "Short", + Some("int") => "Int", + Some("long") => "Long", + Some("float") => "Float", + Some("double") => "Double", + _ => "", + } + .to_string(), + ) + } + + /// Returns true if the argument's FFI type is a primitive where the Java type matches + /// the FFI type directly (no conversion needed). Used to skip lower_fn for primitive args. + /// Excludes boolean (Java `boolean` vs FFI `byte`). + pub fn has_primitive_ffi_type( + as_ct: &impl AsCodeType, + _v: &dyn askama::Values, + ) -> Result { + Ok(matches!( + as_ct.as_codetype().type_label_primitive().as_deref(), + Some("byte" | "short" | "int" | "long" | "float" | "double") + )) + } + + /// Maps FfiType to ValueLayout constant for FunctionDescriptor + pub fn ffi_value_layout( type_: &FfiType, _v: &dyn askama::Values, - config: &Config, - ci: &ComponentInterface, ) -> Result { - Ok(JavaCodeOracle.ffi_type_label_for_ffi_struct(type_, config, ci)) + Ok(JavaCodeOracle.ffi_value_layout(type_)) } - pub fn ffi_default_value( - type_: FfiType, + /// Generate the full structLayout body for an FfiStruct, with computed padding + pub fn ffi_struct_layout_body( + ffi_struct: &uniffi_bindgen::interface::FfiStruct, _v: &dyn askama::Values, ) -> Result { - Ok(JavaCodeOracle.ffi_default_value(&type_)) + let mut parts = Vec::new(); + let mut offset: usize = 0; + for field in ffi_struct.fields() { + let field_type = field.type_(); + let alignment = JavaCodeOracle.ffi_type_alignment(&field_type); + // Insert padding if needed for alignment + let padding = if !offset.is_multiple_of(alignment) { + alignment - (offset % alignment) + } else { + 0 + }; + if padding > 0 { + parts.push(format!( + "java.lang.foreign.MemoryLayout.paddingLayout({})", + padding + )); + offset += padding; + } + let layout = JavaCodeOracle.ffi_struct_field_layout(&field_type); + let field_name = JavaCodeOracle.var_name_raw(field.name()); + parts.push(format!("{}.withName(\"{}\")", layout, field_name)); + // Advance offset + let size = JavaCodeOracle.ffi_type_size(&field_type); + if size > 0 { + offset += size; + } else { + // Unknown size (e.g., user-defined FfiStruct) — can't compute further padding + // but alignment was already handled + offset = 0; // reset; further padding may be wrong but this is rare + } + } + Ok(parts.join(",\n ")) } - /// FFI type name preferring primitive types (for JNA direct mapping native methods) - pub fn ffi_type_name_primitive( + /// Maps FfiType to UNALIGNED ValueLayout for struct field access + pub fn ffi_value_layout_unaligned( type_: &FfiType, _v: &dyn askama::Values, - config: &Config, - ci: &ComponentInterface, ) -> Result { - Ok(JavaCodeOracle.ffi_type_label_by_value(type_, true, config, ci)) + Ok(JavaCodeOracle.ffi_value_layout_unaligned(type_)) + } + + /// Cast prefix for invokeExact() return values + pub fn ffi_invoke_exact_cast( + type_: &FfiType, + _v: &dyn askama::Values, + ) -> Result { + Ok(JavaCodeOracle.ffi_invoke_exact_cast(type_)) + } + + /// Returns true if the FFI return type is a struct needing SegmentAllocator + pub fn ffi_type_is_struct( + type_: &FfiType, + _v: &dyn askama::Values, + ) -> Result { + Ok(JavaCodeOracle.ffi_type_is_struct(type_)) + } + + /// Returns true if this is an embedded struct (slice-based access in struct fields) + pub fn ffi_type_is_embedded_struct( + type_: &FfiType, + _v: &dyn askama::Values, + ) -> Result { + Ok(JavaCodeOracle.ffi_type_is_embedded_struct(type_)) + } + + /// Get the struct class name for an FFI struct type + pub fn ffi_struct_type_name( + type_: &FfiType, + _v: &dyn askama::Values, + ) -> Result { + Ok(JavaCodeOracle.ffi_struct_type_name(type_)) + } + + /// FFI type name using boxed types for generic contexts (accepts high-level Type) + pub fn ffi_type_name_boxed( + type_: &impl AsType, + _v: &dyn askama::Values, + ) -> Result { + let ffi_type: FfiType = type_.as_type().into(); + Ok(JavaCodeOracle.ffi_type_label_boxed(&ffi_type)) } /// Get the interface name for a trait implementation (for external trait interfaces). @@ -1141,7 +1307,7 @@ mod filters { ) -> Result { let ffi_func = callable.ffi_rust_future_poll(ci); Ok(format!( - "(future, callback, continuation) -> UniffiLib.{ffi_func}(future, callback, continuation)" + "(future, callback, continuationHandle) -> UniffiLib.{ffi_func}(future, callback, continuationHandle)" )) } @@ -1149,33 +1315,18 @@ mod filters { callable: impl Callable, _v: &dyn askama::Values, ci: &ComponentInterface, - config: &Config, + _config: &Config, ) -> Result { let ffi_func = callable.ffi_rust_future_complete(ci); - let call = format!("UniffiLib.{ffi_func}(future, continuation)"); - let call = match callable.return_type() { - Some(return_type) if ci.is_external(return_type) => { - let ffi_type = FfiType::from(return_type); - match ffi_type { - FfiType::RustBuffer(Some(ExternalFfiMetadata { name, module_path })) => { - // Need to convert the RustBuffer from our package to the RustBuffer of the external package - let rust_buffer = format!( - "{}.RustBuffer", - config.external_type_package_name(&module_path, &name) - ); - format!( - "(future, continuation) -> {{ - var result = {call}; - return {rust_buffer}.create(result.capacity, result.len, result.data); - }}" - ) - } - _ => call, - } - } - _ => format!("(future, continuation) -> {call}"), - }; - Ok(call) + // The complete function returns a RustBuffer for types that use RustBuffer FFI, + // which is a struct and needs a SegmentAllocator. + let needs_allocator = callable.return_type().is_some_and(|t| { + let ffi_type: FfiType = t.into(); + JavaCodeOracle.ffi_type_is_struct(&ffi_type) + }); + let allocator_arg = if needs_allocator { "_allocator, " } else { "" }; + let call = format!("UniffiLib.{ffi_func}({allocator_arg}future, continuation)"); + Ok(format!("(_allocator, future, continuation) -> {call}")) } pub fn async_free( @@ -1558,4 +1709,52 @@ mod tests { "should not contain List" ); } + + #[test] + fn android_replaces_ffm_package() { + let mut group = MetadataGroup { + namespace: NamespaceMetadata { + crate_name: "test".to_string(), + name: "test".to_string(), + }, + namespace_docstring: None, + items: Default::default(), + }; + group.add_item(Metadata::Func(FnMetadata { + module_path: "test".to_string(), + name: "noop".to_string(), + is_async: false, + inputs: vec![], + return_type: None, + throws: None, + checksum: None, + docstring: None, + })); + + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + + let android_config: Config = toml::from_str("android = true").unwrap(); + + let bindings = generate_bindings(&android_config, &ci).unwrap(); + + assert!( + !bindings.contains("java.lang.foreign."), + "android bindings should not contain java.lang.foreign" + ); + assert!( + bindings.contains("com.v7878.foreign."), + "android bindings should contain com.v7878.foreign" + ); + // MethodHandle/MethodHandles/MethodType should NOT be replaced + assert!( + bindings.contains("java.lang.invoke.MethodHandle"), + "android bindings should preserve java.lang.invoke.MethodHandle" + ); + // Standard java.lang types should NOT be replaced + assert!( + bindings.contains("java.lang.Exception"), + "android bindings should preserve java.lang.Exception" + ); + } } diff --git a/src/templates/Async.java b/src/templates/Async.java index fc28b03..18cea24 100644 --- a/src/templates/Async.java +++ b/src/templates/Async.java @@ -7,19 +7,41 @@ public final class UniffiAsyncHelpers { static final UniffiHandleMap> uniffiContinuationHandleMap = new UniffiHandleMap<>(); static final UniffiHandleMap uniffiForeignFutureHandleMap = new UniffiHandleMap<>(); - // FFI type for Rust future continuations - enum UniffiRustFutureContinuationCallbackImpl implements UniffiRustFutureContinuationCallback { - INSTANCE; - - @Override - public void callback(long data, byte pollResult) { - uniffiContinuationHandleMap.remove(data).complete(pollResult); + // Singleton upcall stub for the continuation callback, created once and reused + private static final java.lang.foreign.MemorySegment CONTINUATION_CALLBACK_STUB; + static { + try { + java.lang.invoke.MethodHandle handle = java.lang.invoke.MethodHandles.lookup() + .findStatic(UniffiAsyncHelpers.class, "continuationCallback", + java.lang.invoke.MethodType.methodType(void.class, long.class, byte.class)); + CONTINUATION_CALLBACK_STUB = java.lang.foreign.Linker.nativeLinker().upcallStub( + handle, + java.lang.foreign.FunctionDescriptor.ofVoid(java.lang.foreign.ValueLayout.JAVA_LONG, java.lang.foreign.ValueLayout.JAVA_BYTE), + java.lang.foreign.Arena.ofAuto() + ); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError("Failed to create continuation callback stub", e); } } + // The actual callback implementation invoked from native code + static void continuationCallback(long data, byte pollResult) { + uniffiContinuationHandleMap.remove(data).complete(pollResult); + } + @FunctionalInterface interface PollingFunction { - void apply(long rustFuture, UniffiRustFutureContinuationCallback callback, long continuationHandle); + void apply(long rustFuture, java.lang.foreign.MemorySegment callback, long continuationHandle); + } + + @FunctionalInterface + interface AsyncCompleteFunction { + F apply(java.lang.foreign.SegmentAllocator allocator, long rustFuture, java.lang.foreign.MemorySegment status); + } + + @FunctionalInterface + interface AsyncCompleteVoidFunction { + void apply(java.lang.foreign.SegmentAllocator allocator, long rustFuture, java.lang.foreign.MemorySegment status); } static class UniffiFreeingFuture extends java.util.concurrent.CompletableFuture { @@ -47,9 +69,8 @@ public boolean cancel(boolean ignored) { } } - // helper so both the Java completable future and the job that handles it finishing and reports to Rust can be - // retrieved (and potentially cancelled) by handle. This allows our FreeImpl to be a parameterless singleton, - // preventing #19, which was caused by our FreeImpls being GCd before Rust called back into them. + // Helper so both the Java completable future and the job that handles it finishing and + // reports to Rust can be retrieved (and potentially cancelled) by handle. static class CancelableForeignFuture { private java.util.concurrent.CompletableFuture childFuture; private java.util.concurrent.CompletableFuture childFutureHandler; @@ -71,7 +92,7 @@ static java.util.concurrent.CompletableFut java.util.concurrent.Executor uniffiExecutor, long rustFuture, PollingFunction pollFunc, - java.util.function.BiFunction completeFunc, + AsyncCompleteFunction completeFunc, java.util.function.Consumer freeFunc, java.util.function.Function liftFunc, UniffiRustCallStatusErrorHandler errorHandler @@ -92,8 +113,8 @@ static java.util.concurrent.CompletableFut return null; } try { - F result = UniffiHelpers.uniffiRustCallWithError(errorHandler, status -> { - return completeFunc.apply(rustFuture, status); + F result = UniffiHelpers.uniffiRustCallWithError(errorHandler, (_allocator, status) -> { + return completeFunc.apply(_allocator, rustFuture, status); }); return liftFunc.apply(result); } catch (java.lang.Exception e) { @@ -106,7 +127,6 @@ static java.util.concurrent.CompletableFut try { // If we failed in the chain somewhere, now complete the future with the failure if (throwable != null) { - // Unwrap CompletionException to expose the original exception java.lang.Throwable cause = throwable; if (cause instanceof java.util.concurrent.CompletionException && cause.getCause() != null) { cause = cause.getCause(); @@ -124,13 +144,13 @@ static java.util.concurrent.CompletableFut } - // overload specifically for Void cases, which aren't within the Object type. - // This is only necessary because of Java's lack of proper Any/Unit + // Overload specifically for Void cases, which aren't within the Object type. + // This is only necessary because of Java's lack of proper Any/Unit. static java.util.concurrent.CompletableFuture uniffiRustCallAsync( java.util.concurrent.Executor uniffiExecutor, long rustFuture, PollingFunction pollFunc, - java.util.function.BiConsumer completeFunc, + AsyncCompleteVoidFunction completeFunc, java.util.function.Consumer freeFunc, java.lang.Runnable liftFunc, UniffiRustCallStatusErrorHandler errorHandler @@ -151,8 +171,8 @@ static java.util.concurrent.CompletableFuture { - completeFunc.accept(rustFuture, status); + UniffiHelpers.uniffiRustCallWithError(errorHandler, (_allocator, status) -> { + completeFunc.apply(_allocator, rustFuture, status); }); } catch (java.lang.Exception e) { throw new java.util.concurrent.CompletionException(e); @@ -163,6 +183,7 @@ static java.util.concurrent.CompletableFuture java.util.concurrent.CompletableFuture pollUntilReady(long rustFuture, PollingFunction pollFunc, java.util.concurrent.Executor uniffiExecutor) { java.util.concurrent.CompletableFuture pollFuture = new java.util.concurrent.CompletableFuture<>(); var handle = uniffiContinuationHandleMap.insert(pollFuture); - pollFunc.apply(rustFuture, UniffiRustFutureContinuationCallbackImpl.INSTANCE, handle); + pollFunc.apply(rustFuture, CONTINUATION_CALLBACK_STUB, handle); return pollFuture.thenComposeAsync(pollResult -> { if (pollResult == UNIFFI_RUST_FUTURE_POLL_READY) { return java.util.concurrent.CompletableFuture.completedFuture(null); @@ -192,16 +213,20 @@ private static java.util.concurrent.CompletableFuture pollUntilR } }, uniffiExecutor); } - + {%- if ci.has_async_callback_interface_definition() %} static void uniffiTraitInterfaceCallAsync( java.util.function.Supplier> makeCall, java.util.function.Consumer handleSuccess, - java.util.function.Consumer handleError, - UniffiForeignFutureDroppedCallbackStruct uniffiOutDroppedCallback + java.util.function.Consumer handleError, + java.lang.foreign.MemorySegment uniffiOutDroppedCallback ){ // Uniffi does its best to support structured concurrency across the FFI. - // If the Rust future is dropped, `uniffiForeignFutureDroppedCallbackImpl` is called, which will cancel the Java completable future if it's still running. + // If the Rust future is dropped, the free callback is invoked, which will + // cancel the Java completable future if it's still running. + + // Upcall parameter segments have zero size; reinterpret to actual struct size + uniffiOutDroppedCallback = uniffiOutDroppedCallback.reinterpret({{ "ForeignFutureDroppedCallbackStruct"|ffi_struct_name }}.LAYOUT.byteSize()); var foreignFutureCf = makeCall.get(); java.util.concurrent.CompletableFuture ffHandler = java.util.concurrent.CompletableFuture.supplyAsync(() -> { // Note: it's important we call either `handleSuccess` or `handleError` exactly once. @@ -209,14 +234,14 @@ static void uniffiTraitInterfaceCallAsync( // a double call. The following code is structured so that we will never call both // `handleSuccess` and `handleError`, even in the face of weird exceptions. // - // In extreme circumstances we may not call either, for example if we fail to make the - // JNA call to `handleSuccess`. This means we will leak the Arc reference, which is + // In extreme circumstances we may not call either, for example if we fail to invoke + // `handleSuccess`. This means we will leak the Arc reference, which is // better than double-freeing it. T callResult; try { callResult = foreignFutureCf.get(); } catch(java.lang.Throwable e) { - // if we errored inside the CF, it's that error we want to send to Rust, not the wrapper + // If we errored inside the CF, it's that error we want to send to Rust, not the wrapper if (e instanceof java.util.concurrent.ExecutionException) { e = e.getCause(); } @@ -232,20 +257,22 @@ static void uniffiTraitInterfaceCallAsync( return null; }); long handle = uniffiForeignFutureHandleMap.insert(new CancelableForeignFuture(foreignFutureCf, ffHandler)); - uniffiOutDroppedCallback.uniffiSetValue( - new UniffiForeignFutureDroppedCallbackStruct(handle, uniffiForeignFutureDroppedCallbackImpl.INSTANCE) - ); + // Write handle and free callback into the ForeignFuture struct + {{ "ForeignFutureDroppedCallbackStruct"|ffi_struct_name }}.sethandle(uniffiOutDroppedCallback, handle); + {{ "ForeignFutureDroppedCallbackStruct"|ffi_struct_name }}.setfree(uniffiOutDroppedCallback, FOREIGN_FUTURE_DROPPED_CALLBACK_STUB); } @SuppressWarnings("unchecked") static void uniffiTraitInterfaceCallAsyncWithError( java.util.function.Supplier> makeCall, java.util.function.Consumer handleSuccess, - java.util.function.Consumer handleError, - java.util.function.Function lowerError, + java.util.function.Consumer handleError, + java.util.function.Function lowerError, java.lang.Class errorClass, - UniffiForeignFutureDroppedCallbackStruct uniffiOutDroppedCallback + java.lang.foreign.MemorySegment uniffiOutDroppedCallback ){ + // Upcall parameter segments have zero size; reinterpret to actual struct size + uniffiOutDroppedCallback = uniffiOutDroppedCallback.reinterpret({{ "ForeignFutureDroppedCallbackStruct"|ffi_struct_name }}.LAYOUT.byteSize()); var foreignFutureCf = makeCall.get(); java.util.concurrent.CompletableFuture ffHandler = java.util.concurrent.CompletableFuture.supplyAsync(() -> { // See the note in uniffiTraitInterfaceCallAsync for details on `handleSuccess` and @@ -254,7 +281,7 @@ static void uniffiTraitInterfaceCallAsyncWith try { callResult = foreignFutureCf.get(); } catch (java.lang.Throwable e) { - // if we errored inside the CF, it's that error we want to send to Rust, not the wrapper + // If we errored inside the CF, it's that error we want to send to Rust, not the wrapper if (e instanceof java.util.concurrent.ExecutionException) { e = e.getCause(); } @@ -280,21 +307,32 @@ static void uniffiTraitInterfaceCallAsyncWith }); long handle = uniffiForeignFutureHandleMap.insert(new CancelableForeignFuture(foreignFutureCf, ffHandler)); - uniffiOutDroppedCallback.uniffiSetValue( - new UniffiForeignFutureDroppedCallbackStruct(handle, uniffiForeignFutureDroppedCallbackImpl.INSTANCE) - ); + {{ "ForeignFutureDroppedCallbackStruct"|ffi_struct_name }}.sethandle(uniffiOutDroppedCallback, handle); + {{ "ForeignFutureDroppedCallbackStruct"|ffi_struct_name }}.setfree(uniffiOutDroppedCallback, FOREIGN_FUTURE_DROPPED_CALLBACK_STUB); } - enum uniffiForeignFutureDroppedCallbackImpl implements UniffiForeignFutureDroppedCallback { - INSTANCE; - - @Override - public void callback(long handle) { - var futureWithHandler = uniffiForeignFutureHandleMap.remove(handle); - futureWithHandler.cancel(); + // Singleton upcall stub for foreign future dropped callback + private static final java.lang.foreign.MemorySegment FOREIGN_FUTURE_DROPPED_CALLBACK_STUB; + static { + try { + java.lang.invoke.MethodHandle handle = java.lang.invoke.MethodHandles.lookup() + .findStatic(UniffiAsyncHelpers.class, "foreignFutureDroppedCallback", + java.lang.invoke.MethodType.methodType(void.class, long.class)); + FOREIGN_FUTURE_DROPPED_CALLBACK_STUB = java.lang.foreign.Linker.nativeLinker().upcallStub( + handle, + java.lang.foreign.FunctionDescriptor.ofVoid(java.lang.foreign.ValueLayout.JAVA_LONG), + java.lang.foreign.Arena.ofAuto() + ); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError("Failed to create foreign future dropped callback stub", e); } } + static void foreignFutureDroppedCallback(long handle) { + var futureWithHandler = uniffiForeignFutureHandleMap.remove(handle); + futureWithHandler.cancel(); + } + // For testing public static int uniffiForeignFutureHandleCount() { return uniffiForeignFutureHandleMap.size(); diff --git a/src/templates/CallbackInterfaceImpl.java b/src/templates/CallbackInterfaceImpl.java index ab653ca..a228fb2 100644 --- a/src/templates/CallbackInterfaceImpl.java +++ b/src/templates/CallbackInterfaceImpl.java @@ -2,47 +2,48 @@ package {{ config.package_name() }}; -import com.sun.jna.*; -import com.sun.jna.ptr.*; - {%- let trait_impl=format!("UniffiCallbackInterface{}", name) %} // Put the implementation in an object so we don't pollute the top-level namespace public class {{ trait_impl }} { public static final {{ trait_impl }} INSTANCE = new {{ trait_impl }}(); - {{ vtable|ffi_type_name_by_value(config, ci) }} vtable; - + java.lang.foreign.MemorySegment vtable; + {{ trait_impl }}() { - vtable = new {{ vtable|ffi_type_name_by_value(config, ci) }}( - UniffiFree.INSTANCE, - UniffiClone.INSTANCE, - {%- for (ffi_callback, meth) in vtable_methods.iter() %} - {{ meth.name()|var_name }}.INSTANCE{% if !loop.last %},{% endif %} - {%- endfor %} - ); + // Use Arena.global() for vtable and upcall stubs — they live for the program lifetime. + // Arena.ofAuto() stubs can be GC'd since storing an address in a struct doesn't + // prevent the Arena from being collected. + vtable = java.lang.foreign.Arena.global().allocate({{ vtable|ffi_struct_type_name }}.LAYOUT); + {{ vtable|ffi_struct_type_name }}.setuniffiFree(vtable, {{ "CallbackInterfaceFree"|ffi_callback_name }}.toUpcallStub(UniffiFree.INSTANCE, java.lang.foreign.Arena.global())); + {{ vtable|ffi_struct_type_name }}.setuniffiClone(vtable, {{ "CallbackInterfaceClone"|ffi_callback_name }}.toUpcallStub(UniffiClone.INSTANCE, java.lang.foreign.Arena.global())); + {%- for (ffi_callback, meth) in vtable_methods.iter() %} + {{ vtable|ffi_struct_type_name }}.set{{ meth.name()|var_name_raw }}(vtable, {{ ffi_callback.name()|ffi_callback_name }}.toUpcallStub({{ meth.name()|var_name }}.INSTANCE, java.lang.foreign.Arena.global())); + {%- endfor %} } - + // Registers the foreign callback with the Rust side. - // This method is generated for each callback interface. void register() { UniffiLib.{{ ffi_init_callback.name() }}(vtable); - } + } {%- for (ffi_callback, meth) in vtable_methods.iter() %} {% let inner_method_class = meth.name()|var_name %} - public static class {{ inner_method_class }} implements {{ ffi_callback.name()|ffi_callback_name }} { + public static class {{ inner_method_class }} implements {{ ffi_callback.name()|ffi_callback_name }}.Fn { public static final {{ inner_method_class }} INSTANCE = new {{ inner_method_class }}(); private {{ inner_method_class }}() {} @Override - public {% match ffi_callback.return_type() %}{% when Some(return_type) %}{{ return_type|ffi_type_name_for_ffi_struct(config, ci) }}{% when None %}void{% endmatch %} callback( + public {% match ffi_callback.return_type() %}{% when Some(return_type) %}{{ return_type|ffi_type_name(config, ci) }}{% when None %}void{% endmatch %} callback( {%- for arg in ffi_callback.arguments() -%} - {{ arg.type_().borrow()|ffi_type_name_for_ffi_struct(config, ci) }} {{ arg.name().borrow()|var_name }}{% if !loop.last || (loop.last && ffi_callback.has_rust_call_status_arg()) %},{% endif %} + {{ arg.type_().borrow()|ffi_type_name(config, ci) }} {{ arg.name().borrow()|var_name }}{% if !loop.last || (loop.last && ffi_callback.has_rust_call_status_arg()) %},{% endif %} {%- endfor -%} {%- if ffi_callback.has_rust_call_status_arg() -%} - UniffiRustCallStatus uniffiCallStatus + java.lang.foreign.MemorySegment uniffiCallStatus {%- endif -%} ) { + {%- if ffi_callback.has_rust_call_status_arg() %} + uniffiCallStatus = uniffiCallStatus.reinterpret(UniffiRustCallStatus.LAYOUT.byteSize()); + {%- endif %} var uniffiObj = {{ ffi_converter_name }}.INSTANCE.handleMap.get(uniffiHandle); {% if !meth.is_async() && meth.throws_type().is_some() %}java.util.concurrent.Callable{% else %}java.util.function.Supplier{%endif%}<{% if meth.is_async() %}{{ meth|async_return_type(ci, config) }}{% else %}{% match meth.return_type() %}{% when Some(return_type)%}{{ return_type|type_name(ci, config)}}{% when None %}java.lang.Void{% endmatch %}{% endif %}> makeCall = () -> { {% if meth.return_type().is_some() || meth.is_async() %}return {% endif %}uniffiObj.{{ meth.name()|fn_name() }}( @@ -55,7 +56,17 @@ public static class {{ inner_method_class }} implements {{ ffi_callback.name()|f {%- if !meth.is_async() %} {%- match meth.return_type() %} {%- when Some(return_type) %} - java.util.function.Consumer<{{ return_type|type_name(ci, config)}}> writeReturn = ({{ return_type|type_name(ci, config) }} uniffiValue) -> { uniffiOutReturn.setValue({{ return_type|lower_fn(config, ci) }}(uniffiValue)); }; + {%- let ffi_return_type = return_type|ffi_type %} + java.util.function.Consumer<{{ return_type|type_name(ci, config)}}> writeReturn = ({{ return_type|type_name(ci, config) }} uniffiValue) -> { + {%- if ffi_return_type.borrow()|ffi_type_is_embedded_struct %} + java.lang.foreign.MemorySegment outReturn = uniffiOutReturn.reinterpret({{ ffi_return_type.borrow()|ffi_struct_type_name }}.LAYOUT.byteSize()); + java.lang.foreign.MemorySegment lowered = {{ return_type|lower_fn(config, ci) }}(uniffiValue); + java.lang.foreign.MemorySegment.copy(lowered, 0, outReturn, 0, {{ ffi_return_type.borrow()|ffi_struct_type_name }}.LAYOUT.byteSize()); + {%- else %} + java.lang.foreign.MemorySegment outReturn = uniffiOutReturn.reinterpret({{ ffi_return_type.borrow()|ffi_value_layout }}.byteSize()); + outReturn.set({{ ffi_return_type.borrow()|ffi_value_layout_unaligned }}, 0, {{ return_type|lower_fn(config, ci) }}(uniffiValue)); + {%- endif %} + }; {%- when None %} java.util.function.Consumer writeReturn = (nothing) -> {}; {%- endmatch %} @@ -74,30 +85,47 @@ public static class {{ inner_method_class }} implements {{ ffi_callback.name()|f {%- endmatch %} {%- else %} + {#- Async callback interface method -#} + {%- let result_struct_name = meth.foreign_future_ffi_result_struct().name()|ffi_struct_name %} + // The completion callback always has signature: (long callbackData, java.lang.foreign.MemorySegment result) -> void + java.lang.foreign.FunctionDescriptor uniffiCompletionDescriptor = java.lang.foreign.FunctionDescriptor.ofVoid( + java.lang.foreign.ValueLayout.JAVA_LONG, {{ result_struct_name }}.LAYOUT + ); java.util.function.Consumer<{{ meth|async_inner_return_type(ci, config) }}> uniffiHandleSuccess = ({% match meth.return_type() %}{%- when Some(return_type) %}returnValue{%- when None %}nothing{% endmatch %}) -> { - var uniffiResult = new {{ meth.foreign_future_ffi_result_struct().name()|ffi_struct_name }}.UniffiByValue( - {%- match meth.return_type() %} - {%- when Some(return_type) %} - {{ return_type|lower_fn(config, ci) }}(returnValue), - {%- when None %} - {%- endmatch %} - new UniffiRustCallStatus.ByValue() - ); - uniffiResult.write(); - uniffiFutureCallback.callback(uniffiCallbackData, uniffiResult); + java.lang.foreign.MemorySegment uniffiResult = java.lang.foreign.Arena.ofAuto().allocate({{ result_struct_name }}.LAYOUT); + {%- match meth.return_type() %} + {%- when Some(return_type) %} + {%- let ffi_return_type = return_type|ffi_type %} + {%- if ffi_return_type.borrow()|ffi_type_is_embedded_struct %} + java.lang.foreign.MemorySegment lowered = {{ return_type|lower_fn(config, ci) }}(returnValue); + {{ result_struct_name }}.setreturnValue(uniffiResult, lowered); + {%- else %} + {{ result_struct_name }}.setreturnValue(uniffiResult, {{ return_type|lower_fn(config, ci) }}(returnValue)); + {%- endif %} + {%- when None %} + {%- endmatch %} + // Set status to success (zeroed out already) + try { + // Convert the upcall java.lang.foreign.MemorySegment to a globally-scoped one for cross-thread use + java.lang.foreign.MemorySegment globalCallback = java.lang.foreign.MemorySegment.ofAddress(uniffiFutureCallback.address()); + java.lang.invoke.MethodHandle mh = java.lang.foreign.Linker.nativeLinker().downcallHandle( + globalCallback, uniffiCompletionDescriptor); + mh.invokeExact(uniffiCallbackData, uniffiResult); + } catch (Throwable t) { + throw new AssertionError("invokeExact failed", t); + } }; - java.util.function.Consumer uniffiHandleError = (callStatus) -> { - uniffiFutureCallback.callback( - uniffiCallbackData, - new {{ meth.foreign_future_ffi_result_struct().name()|ffi_struct_name }}.UniffiByValue( - {%- match meth.return_type() %} - {%- when Some(return_type) %} - {{ return_type.into()|ffi_default_value }}, - {%- when None %} - {%- endmatch %} - callStatus - ) - ); + java.util.function.Consumer uniffiHandleError = (callStatus) -> { + java.lang.foreign.MemorySegment uniffiResult = java.lang.foreign.Arena.ofAuto().allocate({{ result_struct_name }}.LAYOUT); + {{ result_struct_name }}.setcallStatus(uniffiResult, callStatus); + try { + java.lang.foreign.MemorySegment globalCallback = java.lang.foreign.MemorySegment.ofAddress(uniffiFutureCallback.address()); + java.lang.invoke.MethodHandle mh = java.lang.foreign.Linker.nativeLinker().downcallHandle( + globalCallback, uniffiCompletionDescriptor); + mh.invokeExact(uniffiCallbackData, uniffiResult); + } catch (Throwable t) { + throw new AssertionError("invokeExact failed", t); + } }; {%- match meth.throws_type() %} @@ -123,7 +151,7 @@ public static class {{ inner_method_class }} implements {{ ffi_callback.name()|f } {%- endfor %} - public static class UniffiFree implements {{ "CallbackInterfaceFree"|ffi_callback_name }} { + public static class UniffiFree implements {{ "CallbackInterfaceFree"|ffi_callback_name }}.Fn { public static final UniffiFree INSTANCE = new UniffiFree(); private UniffiFree() {} @@ -134,7 +162,7 @@ public void callback(long handle) { } } - public static class UniffiClone implements {{ "CallbackInterfaceClone"|ffi_callback_name }} { + public static class UniffiClone implements {{ "CallbackInterfaceClone"|ffi_callback_name }}.Fn { public static final UniffiClone INSTANCE = new UniffiClone(); private UniffiClone() {} diff --git a/src/templates/CustomTypeTemplate.java b/src/templates/CustomTypeTemplate.java index f0ae8c7..2346013 100644 --- a/src/templates/CustomTypeTemplate.java +++ b/src/templates/CustomTypeTemplate.java @@ -1,5 +1,6 @@ {%- let package_name = config.package_name() %} -{%- let ffi_type_name=builtin|ffi_type|ref|ffi_type_name_by_value(config, ci) %} +{%- let ffi_type_name=builtin|ffi_type|ref|ffi_type_name(config, ci) %} +{%- let ffi_type_name_boxed=builtin|ffi_type_name_boxed %} {%- match config.custom_types.get(name.as_str()) %} {%- when None %} {#- Define a newtype record that delegates to the builtin #} @@ -13,17 +14,15 @@ public record {{ type_name }}( package {{ package_name }}; -import com.sun.jna.Pointer; - -public enum {{ ffi_converter_name }} implements FfiConverter<{{ type_name }}, {{ ffi_type_name}}> { +public enum {{ ffi_converter_name }} implements FfiConverter<{{ type_name }}, {{ ffi_type_name_boxed }}> { INSTANCE; @Override - public {{ type_name }} lift({{ ffi_type_name }} value) { + public {{ type_name }} lift({{ ffi_type_name_boxed }} value) { var builtinValue = {{ builtin|lift_fn(config, ci) }}(value); return new {{ type_name }}(builtinValue); } @Override - public {{ ffi_type_name }} lower({{ type_name }} value) { + public {{ ffi_type_name_boxed }} lower({{ type_name }} value) { var builtinValue = value.value(); return {{ builtin|lower_fn(config, ci) }}(builtinValue); } @@ -72,8 +71,6 @@ public record {{ type_name }}( package {{ package_name }}; -import com.sun.jna.Pointer; - {%- match custom_type_config.imports %} {%- when Some(imports) %} {%- for import_name in imports %} @@ -85,7 +82,7 @@ public record {{ type_name }}( public enum {{ ffi_converter_name }} implements FfiConverter<{{ type_name }}, {{ ffi_type_name }}> { INSTANCE; @Override - public {{ type_name }} lift({{ ffi_type_name }} value) { + public {{ type_name }} lift({{ ffi_type_name_boxed }} value) { var builtinValue = {{ builtin|lift_fn(config, ci) }}(value); try{ return new {{ type_name}}({{ custom_type_config.lift("builtinValue") }}); @@ -94,7 +91,7 @@ public enum {{ ffi_converter_name }} implements FfiConverter<{{ type_name }}, {{ } } @Override - public {{ ffi_type_name }} lower({{ type_name }} value) { + public {{ ffi_type_name_boxed }} lower({{ type_name }} value) { try{ var builtinValue = {{ custom_type_config.lower("value.value()") }}; return {{ builtin|lower_fn(config, ci) }}(builtinValue); diff --git a/src/templates/ErrorTemplate.java b/src/templates/ErrorTemplate.java index 6d919bf..fe13136 100644 --- a/src/templates/ErrorTemplate.java +++ b/src/templates/ErrorTemplate.java @@ -82,7 +82,7 @@ void close() { public class {{ type_name }}ErrorHandler implements UniffiRustCallStatusErrorHandler<{{ type_name }}> { @Override - public {{ type_name }} lift(RustBuffer.ByValue errorBuf){ + public {{ type_name }} lift(java.lang.foreign.MemorySegment errorBuf){ return {{ ffi_converter_instance }}.lift(errorBuf); } } diff --git a/src/templates/ExternalTypeTemplate.java b/src/templates/ExternalTypeTemplate.java index 897f6ed..bfa0a69 100644 --- a/src/templates/ExternalTypeTemplate.java +++ b/src/templates/ExternalTypeTemplate.java @@ -7,13 +7,9 @@ public class {{ class_name }}ExternalErrorHandler implements UniffiRustCallStatusErrorHandler<{{ external_package_name }}.{{ class_name }}> { @Override - public {{ external_package_name }}.{{ class_name }} lift(RustBuffer.ByValue errorBuf) { - // Convert local RustBuffer to external package's RustBuffer - {{ external_package_name }}.RustBuffer.ByValue externalBuf = new {{ external_package_name }}.RustBuffer.ByValue(); - externalBuf.capacity = errorBuf.capacity; - externalBuf.len = errorBuf.len; - externalBuf.data = errorBuf.data; - return new {{ external_package_name }}.{{ class_name }}ErrorHandler().lift(externalBuf); + public {{ external_package_name }}.{{ class_name }} lift(java.lang.foreign.MemorySegment errorBuf) { + // In FFM, RustBuffer is already a java.lang.foreign.MemorySegment — pass directly to external package + return new {{ external_package_name }}.{{ class_name }}ErrorHandler().lift(errorBuf); } } {%- endif %} diff --git a/src/templates/FfiConverterTemplate.java b/src/templates/FfiConverterTemplate.java index 22c3e59..328d695 100644 --- a/src/templates/FfiConverterTemplate.java +++ b/src/templates/FfiConverterTemplate.java @@ -33,13 +33,12 @@ public interface FfiConverter { // FfiType. It's used by the callback interface code. Callback interface // returns are always serialized into a `RustBuffer` regardless of their // normal FFI type. - default RustBuffer.ByValue lowerIntoRustBuffer(JavaType value) { - RustBuffer.ByValue rbuf = RustBuffer.alloc(allocationSize(value)); + default java.lang.foreign.MemorySegment lowerIntoRustBuffer(JavaType value) { + java.lang.foreign.MemorySegment rbuf = RustBuffer.alloc(allocationSize(value)); try { - java.nio.ByteBuffer bbuf = rbuf.data.getByteBuffer(0, rbuf.capacity); - bbuf.order(java.nio.ByteOrder.BIG_ENDIAN); + java.nio.ByteBuffer bbuf = RustBuffer.asWriteByteBuffer(rbuf); write(value, bbuf); - rbuf.writeField("len", (long)bbuf.position()); + RustBuffer.setLen(rbuf, (long) bbuf.position()); return rbuf; } catch (java.lang.Throwable e) { RustBuffer.free(rbuf); @@ -51,8 +50,8 @@ default RustBuffer.ByValue lowerIntoRustBuffer(JavaType value) { // // This here mostly because of the symmetry with `lowerIntoRustBuffer()`. // It's currently only used by the `FfiConverterRustBuffer` class below. - default JavaType liftFromRustBuffer(RustBuffer.ByValue rbuf) { - java.nio.ByteBuffer byteBuf = rbuf.asByteBuffer(); + default JavaType liftFromRustBuffer(java.lang.foreign.MemorySegment rbuf) { + java.nio.ByteBuffer byteBuf = RustBuffer.asByteBuffer(rbuf); try { JavaType item = read(byteBuf); if (byteBuf.hasRemaining()) { @@ -68,13 +67,13 @@ default JavaType liftFromRustBuffer(RustBuffer.ByValue rbuf) { package {{ config.package_name() }}; // FfiConverter that uses `RustBuffer` as the FfiType -public interface FfiConverterRustBuffer extends FfiConverter { +public interface FfiConverterRustBuffer extends FfiConverter { @Override - default JavaType lift(RustBuffer.ByValue value) { + default JavaType lift(java.lang.foreign.MemorySegment value) { return liftFromRustBuffer(value); } @Override - default RustBuffer.ByValue lower(JavaType value) { + default java.lang.foreign.MemorySegment lower(JavaType value) { return lowerIntoRustBuffer(value); } } diff --git a/src/templates/Helpers.java b/src/templates/Helpers.java index a906f27..3dea923 100644 --- a/src/templates/Helpers.java +++ b/src/templates/Helpers.java @@ -1,45 +1,73 @@ package {{ config.package_name() }}; -import com.sun.jna.Structure; -import com.sun.jna.Pointer; +public final class UniffiRustCallStatus { + public static final java.lang.foreign.StructLayout LAYOUT = java.lang.foreign.MemoryLayout.structLayout( + java.lang.foreign.ValueLayout.JAVA_BYTE.withName("code"), + java.lang.foreign.MemoryLayout.paddingLayout(7), // 7 bytes padding for alignment before RustBuffer + RustBuffer.LAYOUT.withName("error_buf") + ); -@Structure.FieldOrder({ "code", "error_buf" }) -public class UniffiRustCallStatus extends Structure { - public byte code; - public RustBuffer.ByValue error_buf; + private static final long OFFSET_CODE = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("code")); + private static final long OFFSET_ERROR_BUF = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("error_buf")); - public static class ByValue extends UniffiRustCallStatus implements Structure.ByValue {} + public static final byte UNIFFI_CALL_SUCCESS = 0; + public static final byte UNIFFI_CALL_ERROR = 1; + public static final byte UNIFFI_CALL_UNEXPECTED_ERROR = 2; + + private UniffiRustCallStatus() {} - public boolean isSuccess() { - return code == UNIFFI_CALL_SUCCESS; + public static byte getCode(java.lang.foreign.MemorySegment seg) { + return seg.get(java.lang.foreign.ValueLayout.JAVA_BYTE, OFFSET_CODE); } - public boolean isError() { - return code == UNIFFI_CALL_ERROR; + public static void setCode(java.lang.foreign.MemorySegment seg, byte value) { + seg.set(java.lang.foreign.ValueLayout.JAVA_BYTE, OFFSET_CODE, value); } - public boolean isPanic() { - return code == UNIFFI_CALL_UNEXPECTED_ERROR; + public static java.lang.foreign.MemorySegment getErrorBuf(java.lang.foreign.MemorySegment seg) { + return seg.asSlice(OFFSET_ERROR_BUF, RustBuffer.LAYOUT.byteSize()); } - public void setCode(byte code) { - this.code = code; + public static void setErrorBuf(java.lang.foreign.MemorySegment seg, java.lang.foreign.MemorySegment errorBuf) { + java.lang.foreign.MemorySegment.copy(errorBuf, 0, seg, OFFSET_ERROR_BUF, RustBuffer.LAYOUT.byteSize()); } - public void setErrorBuf(RustBuffer.ByValue errorBuf) { - this.error_buf = errorBuf; + public static boolean isSuccess(java.lang.foreign.MemorySegment seg) { + return getCode(seg) == UNIFFI_CALL_SUCCESS; } - public static UniffiRustCallStatus.ByValue create(byte code, RustBuffer.ByValue errorBuf) { - UniffiRustCallStatus.ByValue callStatus = new UniffiRustCallStatus.ByValue(); - callStatus.code = code; - callStatus.error_buf = errorBuf; - return callStatus; + public static boolean isError(java.lang.foreign.MemorySegment seg) { + return getCode(seg) == UNIFFI_CALL_ERROR; } - public static final byte UNIFFI_CALL_SUCCESS = 0; - public static final byte UNIFFI_CALL_ERROR = 1; - public static final byte UNIFFI_CALL_UNEXPECTED_ERROR = 2; + public static boolean isPanic(java.lang.foreign.MemorySegment seg) { + return getCode(seg) == UNIFFI_CALL_UNEXPECTED_ERROR; + } + + /** + * Allocate a new RustCallStatus in the given arena. + */ + public static java.lang.foreign.MemorySegment allocate(java.lang.foreign.SegmentAllocator allocator) { + java.lang.foreign.MemorySegment seg = allocator.allocate(LAYOUT); + seg.fill((byte) 0); + return seg; + } + + // Slab allocator for RustCallStatus segments used by async callback error handling. + // See UniffiSlabAllocator for the design rationale. + private static final UniffiSlabAllocator STATUS_ALLOCATOR = new UniffiSlabAllocator(LAYOUT, 1024); + + /** + * Create a RustCallStatus with the given code and error buffer. + * Used by async callback interface error handling. + */ + public static java.lang.foreign.MemorySegment create(byte code, java.lang.foreign.MemorySegment errorBuf) { + java.lang.foreign.MemorySegment seg = STATUS_ALLOCATOR.allocate(LAYOUT); + seg.fill((byte) 0); + setCode(seg, code); + setErrorBuf(seg, errorBuf); + return seg; + } } package {{ config.package_name() }}; @@ -53,7 +81,59 @@ public InternalException(java.lang.String message) { package {{ config.package_name() }}; public interface UniffiRustCallStatusErrorHandler { - E lift(RustBuffer.ByValue errorBuf); + E lift(java.lang.foreign.MemorySegment errorBuf); +} + +package {{ config.package_name() }}; + +// Thread-local slab allocator for short-lived native memory segments. +// +// Several FFM code paths need to allocate small struct-sized segments (RustBuffer +// at 24 bytes, RustCallStatus at 32 bytes) that are consumed immediately and never +// referenced again. +// +// Alternatives considered: +// - Arena.global(): zero overhead but leaks permanently. At high call rates this +// adds up fast (e.g. 100k calls × 24-32 bytes = 2.4-3.2 MB never reclaimed). +// - Arena.ofAuto() per call: correct but creates a new Arena + PhantomReference +// per call, adding ~50-100ns of GC pressure to every call. +// - Thread-local reusable segment: zero overhead but causes SIGABRT — the FFM +// runtime retains internal references to allocator-provided segments, so reusing +// the same segment across calls corrupts FFM's internal state. +// +// This slab approach: allocate a batch of slots from one Arena.ofAuto(), then hand +// out slices. Each call gets a unique slice (avoiding the FFM reuse crash). When the +// slab is exhausted, a new one is allocated and the old one becomes GC-eligible once +// all its slices are consumed (which is immediate — callers read struct fields before +// the next call). Amortized cost: one Arena + one native malloc per `slots` calls. +class UniffiSlabAllocator implements java.lang.foreign.SegmentAllocator { + private final long slabBytes; + private final long alignment; + private final ThreadLocal slab; + private final ThreadLocal offset; + + UniffiSlabAllocator(java.lang.foreign.MemoryLayout layout, long slots) { + this.slabBytes = layout.byteSize() * slots; + this.alignment = layout.byteAlignment(); + this.slab = ThreadLocal.withInitial(() -> + java.lang.foreign.Arena.ofAuto().allocate(this.slabBytes, this.alignment) + ); + this.offset = ThreadLocal.withInitial(() -> new long[]{0}); + } + + @Override + public java.lang.foreign.MemorySegment allocate(long byteSize, long byteAlignment) { + long[] off = this.offset.get(); + java.lang.foreign.MemorySegment s = this.slab.get(); + if (off[0] + byteSize > s.byteSize()) { + s = java.lang.foreign.Arena.ofAuto().allocate(this.slabBytes, this.alignment); + this.slab.set(s); + off[0] = 0; + } + java.lang.foreign.MemorySegment result = s.asSlice(off[0], byteSize); + off[0] += byteSize; + return result; + } } package {{ config.package_name() }}; @@ -61,7 +141,7 @@ public interface UniffiRustCallStatusErrorHandler // UniffiRustCallStatusErrorHandler implementation for times when we don't expect a CALL_ERROR class UniffiNullRustCallStatusErrorHandler implements UniffiRustCallStatusErrorHandler { @Override - public InternalException lift(RustBuffer.ByValue errorBuf) { + public InternalException lift(java.lang.foreign.MemorySegment errorBuf) { RustBuffer.free(errorBuf); return new InternalException("Unexpected CALL_ERROR"); } @@ -73,93 +153,144 @@ public InternalException lift(RustBuffer.ByValue errorBuf) { // In practice we usually need to be synchronized to call this safely, so it doesn't // synchronize itself public final class UniffiHelpers { - // Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err - static U uniffiRustCallWithError(UniffiRustCallStatusErrorHandler errorHandler, java.util.function.Function callback) throws E { - UniffiRustCallStatus status = new UniffiRustCallStatus(); - U returnValue = callback.apply(status); - uniffiCheckCallStatus(errorHandler, status); - return returnValue; - } - - // Overload to call a rust function that returns a Result<()>, because void is outside Java's type system. Pass in the Error class companion that corresponds to the Err - static void uniffiRustCallWithError(UniffiRustCallStatusErrorHandler errorHandler, java.util.function.Consumer callback) throws E { - UniffiRustCallStatus status = new UniffiRustCallStatus(); - callback.accept(status); - uniffiCheckCallStatus(errorHandler, status); - } - - // Check UniffiRustCallStatus and throw an error if the call wasn't successful - static void uniffiCheckCallStatus(UniffiRustCallStatusErrorHandler errorHandler, UniffiRustCallStatus status) throws E { - if (status.isSuccess()) { - return; - } else if (status.isError()) { - throw errorHandler.lift(status.error_buf); - } else if (status.isPanic()) { - // when the rust code sees a panic, it tries to construct a rustbuffer - // with the message. but if that code panics, then it just sends back - // an empty buffer. - if (status.error_buf.len > 0) { - throw new InternalException({{ Type::String.borrow()|lift_fn(config, ci) }}(status.error_buf)); - } else { - throw new InternalException("Rust panic"); - } - } else { - throw new InternalException("Unknown rust call status: " + status.code); - } - } - - // Call a rust function that returns a plain value - static U uniffiRustCall(java.util.function.Function callback) { - return uniffiRustCallWithError(new UniffiNullRustCallStatusErrorHandler(), callback); - } - - // Call a rust function that returns nothing - static void uniffiRustCall(java.util.function.Consumer callback) { - uniffiRustCallWithError(new UniffiNullRustCallStatusErrorHandler(), callback); - } - - static void uniffiTraitInterfaceCall( - UniffiRustCallStatus callStatus, - java.util.function.Supplier makeCall, - java.util.function.Consumer writeReturn - ) { - try { - writeReturn.accept(makeCall.get()); - } catch (java.lang.Exception e) { - callStatus.setCode(UniffiRustCallStatus.UNIFFI_CALL_UNEXPECTED_ERROR); - callStatus.setErrorBuf({{ Type::String.borrow()|lower_fn(config, ci) }}(uniffiStackTraceToString(e))); - } - } - - private static java.lang.String uniffiStackTraceToString(java.lang.Throwable e) { - try { - java.io.StringWriter sw = new java.io.StringWriter(); - e.printStackTrace(new java.io.PrintWriter(sw)); - return sw.toString(); - } catch (java.lang.Throwable _t) { - return e.toString(); - } - } - - static void uniffiTraitInterfaceCallWithError( - UniffiRustCallStatus callStatus, - java.util.concurrent.Callable makeCall, - java.util.function.Consumer writeReturn, - java.util.function.Function lowerError, - java.lang.Class errorClazz - ) { - try { - writeReturn.accept(makeCall.call()); - } catch (java.lang.Exception e) { - if (errorClazz.isAssignableFrom(e.getClass())) { - @SuppressWarnings("unchecked") - E castedE = (E) e; - callStatus.setCode(UniffiRustCallStatus.UNIFFI_CALL_ERROR); - callStatus.setErrorBuf(lowerError.apply(castedE)); - } else { - callStatus.setCode(UniffiRustCallStatus.UNIFFI_CALL_UNEXPECTED_ERROR); - callStatus.setErrorBuf({{ Type::String.borrow()|lower_fn(config, ci) }}(uniffiStackTraceToString(e))); - } - } - } + // Thread-local reusable RustCallStatus to avoid allocation on the hot path + private static final ThreadLocal REUSABLE_STATUS = ThreadLocal.withInitial(() -> + java.lang.foreign.Arena.global().allocate(UniffiRustCallStatus.LAYOUT) + ); + + // Slab allocator for struct return values from FFI downcalls. + // See UniffiSlabAllocator for the design rationale. + private static final UniffiSlabAllocator RETURN_ALLOCATOR = new UniffiSlabAllocator(RustBuffer.LAYOUT, 1024); + + + @FunctionalInterface + public interface UniffiRustCallFunction { + U apply(java.lang.foreign.SegmentAllocator allocator, java.lang.foreign.MemorySegment status); + } + + @FunctionalInterface + public interface UniffiRustCallVoidFunction { + void apply(java.lang.foreign.SegmentAllocator allocator, java.lang.foreign.MemorySegment status); + } + + // Call a rust function that returns a Result<>. Pass in the Error class companion that + // corresponds to the Err. + static U uniffiRustCallWithError( + UniffiRustCallStatusErrorHandler errorHandler, + UniffiRustCallFunction callback) throws E { + java.lang.foreign.MemorySegment status = REUSABLE_STATUS.get(); + status.fill((byte) 0); + U returnValue = callback.apply(RETURN_ALLOCATOR, status); + uniffiCheckCallStatus(errorHandler, status); + return returnValue; + } + + // Overload for void-returning functions + static void uniffiRustCallWithError( + UniffiRustCallStatusErrorHandler errorHandler, + UniffiRustCallVoidFunction callback) throws E { + java.lang.foreign.MemorySegment status = REUSABLE_STATUS.get(); + status.fill((byte) 0); + callback.apply(RETURN_ALLOCATOR, status); + uniffiCheckCallStatus(errorHandler, status); + } + + // Check UniffiRustCallStatus and throw an error if the call wasn't successful + static void uniffiCheckCallStatus( + UniffiRustCallStatusErrorHandler errorHandler, + java.lang.foreign.MemorySegment status) throws E { + if (UniffiRustCallStatus.isSuccess(status)) { + return; + } else if (UniffiRustCallStatus.isError(status)) { + throw errorHandler.lift(UniffiRustCallStatus.getErrorBuf(status)); + } else if (UniffiRustCallStatus.isPanic(status)) { + java.lang.foreign.MemorySegment errorBuf = UniffiRustCallStatus.getErrorBuf(status); + if (RustBuffer.getLen(errorBuf) > 0) { + throw new InternalException({{ Type::String.borrow()|lift_fn(config, ci) }}(errorBuf)); + } else { + throw new InternalException("Rust panic"); + } + } else { + throw new InternalException("Unknown rust call status: " + UniffiRustCallStatus.getCode(status)); + } + } + + // Primitive-specialized variants that avoid autoboxing overhead. + // For each primitive type, we have a functional interface + call + callWithError. + {%- for (prim, suffix) in [("long", "Long"), ("int", "Int"), ("short", "Short"), ("byte", "Byte"), ("float", "Float"), ("double", "Double"), ("boolean", "Boolean")] %} + + @FunctionalInterface + interface UniffiRustCall{{ suffix }}Function { + {{ prim }} apply(java.lang.foreign.SegmentAllocator allocator, java.lang.foreign.MemorySegment status); + } + + static {{ prim }} uniffiRustCallWithError{{ suffix }}( + UniffiRustCallStatusErrorHandler errorHandler, + UniffiRustCall{{ suffix }}Function callback) throws E { + java.lang.foreign.MemorySegment status = REUSABLE_STATUS.get(); + status.fill((byte) 0); + {{ prim }} returnValue = callback.apply(RETURN_ALLOCATOR, status); + uniffiCheckCallStatus(errorHandler, status); + return returnValue; + } + + static {{ prim }} uniffiRustCall{{ suffix }}(UniffiRustCall{{ suffix }}Function callback) { + return uniffiRustCallWithError{{ suffix }}(new UniffiNullRustCallStatusErrorHandler(), callback); + } + {%- endfor %} + + // Call a rust function that returns a plain value + static U uniffiRustCall(UniffiRustCallFunction callback) { + return uniffiRustCallWithError(new UniffiNullRustCallStatusErrorHandler(), callback); + } + + // Call a rust function that returns nothing + static void uniffiRustCall(UniffiRustCallVoidFunction callback) { + uniffiRustCallWithError(new UniffiNullRustCallStatusErrorHandler(), callback); + } + + static void uniffiTraitInterfaceCall( + java.lang.foreign.MemorySegment callStatus, + java.util.function.Supplier makeCall, + java.util.function.Consumer writeReturn + ) { + try { + writeReturn.accept(makeCall.get()); + } catch (java.lang.Exception e) { + UniffiRustCallStatus.setCode(callStatus, UniffiRustCallStatus.UNIFFI_CALL_UNEXPECTED_ERROR); + UniffiRustCallStatus.setErrorBuf(callStatus, {{ Type::String.borrow()|lower_fn(config, ci) }}(uniffiStackTraceToString(e))); + } + } + + private static java.lang.String uniffiStackTraceToString(java.lang.Throwable e) { + try { + java.io.StringWriter sw = new java.io.StringWriter(); + e.printStackTrace(new java.io.PrintWriter(sw)); + return sw.toString(); + } catch (java.lang.Throwable _t) { + return e.toString(); + } + } + + static void uniffiTraitInterfaceCallWithError( + java.lang.foreign.MemorySegment callStatus, + java.util.concurrent.Callable makeCall, + java.util.function.Consumer writeReturn, + java.util.function.Function lowerError, + java.lang.Class errorClazz + ) { + try { + writeReturn.accept(makeCall.call()); + } catch (java.lang.Exception e) { + if (errorClazz.isAssignableFrom(e.getClass())) { + @SuppressWarnings("unchecked") + E castedE = (E) e; + UniffiRustCallStatus.setCode(callStatus, UniffiRustCallStatus.UNIFFI_CALL_ERROR); + UniffiRustCallStatus.setErrorBuf(callStatus, lowerError.apply(castedE)); + } else { + UniffiRustCallStatus.setCode(callStatus, UniffiRustCallStatus.UNIFFI_CALL_UNEXPECTED_ERROR); + UniffiRustCallStatus.setErrorBuf(callStatus, {{ Type::String.borrow()|lower_fn(config, ci) }}(uniffiStackTraceToString(e))); + } + } + } } diff --git a/src/templates/Interface.java b/src/templates/Interface.java index 2266b53..2fd1575 100644 --- a/src/templates/Interface.java +++ b/src/templates/Interface.java @@ -1,8 +1,5 @@ package {{ config.package_name() }}; -import com.sun.jna.*; -import com.sun.jna.ptr.*; - {%- call java::docstring_value(interface_docstring, 0) %} public interface {{ interface_name }} { {% for meth in methods.iter() -%} diff --git a/src/templates/NamespaceLibraryTemplate.java b/src/templates/NamespaceLibraryTemplate.java index fc8a0cf..01ff80c 100644 --- a/src/templates/NamespaceLibraryTemplate.java +++ b/src/templates/NamespaceLibraryTemplate.java @@ -1,34 +1,35 @@ package {{ config.package_name() }}; -import com.sun.jna.Native; - final class NamespaceLibrary { - static synchronized String findLibraryName(String componentName) { - String libOverride = System.getProperty("uniffi.component." + componentName + ".libraryOverride"); - if (libOverride != null) { - return libOverride; + static synchronized String findLibraryName(String componentName) { + String libOverride = System.getProperty("uniffi.component." + componentName + ".libraryOverride"); + if (libOverride != null) { + return libOverride; + } + return "{{ config.cdylib_name() }}"; + } + + static java.lang.foreign.SymbolLookup loadLibrary() { + System.loadLibrary(findLibraryName("{{ ci.namespace() }}")); + return java.lang.foreign.SymbolLookup.loaderLookup(); } - return "{{ config.cdylib_name() }}"; - } - static void uniffiCheckContractApiVersion() { - // Get the bindings contract version from our ComponentInterface - int bindingsContractVersion = {{ ci.uniffi_contract_version() }}; - // Get the scaffolding contract version by calling into the dylib - int scaffoldingContractVersion = IntegrityCheckingUniffiLib.{{ ci.ffi_uniffi_contract_version().name() }}(); - if (bindingsContractVersion != scaffoldingContractVersion) { - throw new RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project"); + static void uniffiCheckContractApiVersion() { + int bindingsContractVersion = {{ ci.uniffi_contract_version() }}; + int scaffoldingContractVersion = UniffiLib.{{ ci.ffi_uniffi_contract_version().name() }}(); + if (bindingsContractVersion != scaffoldingContractVersion) { + throw new RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project"); + } } - } {%- if !config.omit_checksums() %} - static void uniffiCheckApiChecksums() { + static void uniffiCheckApiChecksums() { {%- for (name, expected_checksum) in ci.iter_checksums() %} - if (IntegrityCheckingUniffiLib.{{ name }}() != ((short) {{ expected_checksum }})) { - throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); - } + if (UniffiLib.{{ name }}() != ((short) {{ expected_checksum }})) { + throw new RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project"); + } {%- endfor %} - } + } {%- endif %} } @@ -38,64 +39,82 @@ static void uniffiCheckApiChecksums() { {%- when FfiDefinition::CallbackFunction(callback) %} package {{ config.package_name() }}; -import com.sun.jna.*; -import com.sun.jna.ptr.*; - -interface {{ callback.name()|ffi_callback_name }} extends Callback { - public {% match callback.return_type() %}{%- when Some(return_type) %}{{ return_type|ffi_type_name_for_ffi_struct(config, ci) }}{%- when None %}void{%- endmatch %} callback( - {%- for arg in callback.arguments() -%} - {{ arg.type_().borrow()|ffi_type_name_for_ffi_struct(config, ci) }} {{ arg.name().borrow()|var_name }}{% if !loop.last %},{% endif %} - {%- endfor -%} - {%- if callback.has_rust_call_status_arg() -%}{% if callback.arguments().len() != 0 %},{% endif %} - UniffiRustCallStatus uniffiCallStatus - {%- endif -%} - ); +public final class {{ callback.name()|ffi_callback_name }} { + public static final java.lang.foreign.FunctionDescriptor DESCRIPTOR = java.lang.foreign.FunctionDescriptor.of{% match callback.return_type() %}{%- when Some(return_type) %}({{ return_type|ffi_value_layout }}{%- for arg in callback.arguments() %}, {{ arg.type_().borrow()|ffi_value_layout }}{% endfor %}{%- if callback.has_rust_call_status_arg() %}, java.lang.foreign.ValueLayout.ADDRESS{% endif %}){%- when None %}Void({% for arg in callback.arguments() %}{{ arg.type_().borrow()|ffi_value_layout }}{% if !loop.last %}, {% endif %}{% endfor %}{%- if callback.has_rust_call_status_arg() %}{% if callback.arguments().len() != 0 %}, {% endif %}java.lang.foreign.ValueLayout.ADDRESS{% endif %}){%- endmatch %}; + + @FunctionalInterface + public interface Fn { + {% match callback.return_type() %}{%- when Some(return_type) %}{{ return_type|ffi_type_name(config, ci) }}{%- when None %}void{%- endmatch %} callback( + {%- for arg in callback.arguments() -%} + {{ arg.type_().borrow()|ffi_type_name(config, ci) }} {{ arg.name().borrow()|var_name }}{% if !loop.last %},{% endif %} + {%- endfor -%} + {%- if callback.has_rust_call_status_arg() -%}{% if callback.arguments().len() != 0 %},{% endif %} + java.lang.foreign.MemorySegment uniffiCallStatus + {%- endif -%} + ); + } + + public static java.lang.foreign.MemorySegment toUpcallStub(Fn fn, java.lang.foreign.Arena arena) { + try { + java.lang.invoke.MethodHandle handle = java.lang.invoke.MethodHandles.lookup() + .findVirtual(Fn.class, "callback", java.lang.invoke.MethodType.methodType( + {% match callback.return_type() %}{%- when Some(return_type) %}{{ return_type|ffi_type_name(config, ci) }}.class{%- when None %}void.class{%- endmatch %}, + {%- for arg in callback.arguments() %} + {{ arg.type_().borrow()|ffi_type_name(config, ci) }}.class{% if !loop.last %},{% endif %} + {%- endfor %} + {%- if callback.has_rust_call_status_arg() %} + {% if callback.arguments().len() != 0 %},{% endif %}java.lang.foreign.MemorySegment.class + {%- endif %} + )) + .bindTo(fn); + return java.lang.foreign.Linker.nativeLinker().upcallStub(handle, DESCRIPTOR, arena); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new AssertionError("Failed to create upcall stub", e); + } + } } {%- when FfiDefinition::Struct(ffi_struct) %} package {{ config.package_name() }}; -import com.sun.jna.Structure; -import com.sun.jna.Pointer; - -@Structure.FieldOrder({ {% for field in ffi_struct.fields() %}"{{ field.name()|var_name_raw }}"{% if !loop.last %}, {% endif %}{% endfor %} }) -public class {{ ffi_struct.name()|ffi_struct_name }} extends Structure { +public final class {{ ffi_struct.name()|ffi_struct_name }} { + public static final java.lang.foreign.StructLayout LAYOUT = java.lang.foreign.MemoryLayout.structLayout( + {{ ffi_struct|ffi_struct_layout_body }} + ); {%- for field in ffi_struct.fields() %} - public {{ field.type_().borrow()|ffi_type_name_for_ffi_struct(config, ci) }} {{ field.name()|var_name }} = {{ field.type_()|ffi_default_value }}; + + private static final long OFFSET_{{ field.name()|var_name_raw|fn_name }} = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("{{ field.name()|var_name_raw }}")); {%- endfor %} - // no-arg constructor required so JNA can instantiate and reflect - public {{ ffi_struct.name()|ffi_struct_name}}() { - super(); - } - - public {{ ffi_struct.name()|ffi_struct_name }}( - {%- for field in ffi_struct.fields() %} - {{ field.type_().borrow()|ffi_type_name_for_ffi_struct(config, ci) }} {{ field.name()|var_name }}{% if !loop.last %},{% endif %} - {%- endfor %} - ) { - {%- for field in ffi_struct.fields() %} - this.{{ field.name()|var_name }} = {{ field.name()|var_name }}; - {%- endfor %} - } - - public static class UniffiByValue extends {{ ffi_struct.name()|ffi_struct_name }} implements Structure.ByValue { - public UniffiByValue( - {%- for field in ffi_struct.fields() %} - {{ field.type_().borrow()|ffi_type_name_for_ffi_struct(config, ci) }} {{ field.name()|var_name }}{% if !loop.last %},{% endif %} - {%- endfor %} - ) { - super({%- for field in ffi_struct.fields() -%} - {{ field.name()|var_name }}{% if !loop.last %},{% endif %} - {% endfor %}); - } + private {{ ffi_struct.name()|ffi_struct_name }}() {} + {%- for field in ffi_struct.fields() %} + + {%- if field.type_().borrow()|ffi_type_is_embedded_struct %} + public static java.lang.foreign.MemorySegment get{{ field.name()|var_name_raw }}(java.lang.foreign.MemorySegment seg) { + return seg.asSlice(OFFSET_{{ field.name()|var_name_raw|fn_name }}, {{ field.type_().borrow()|ffi_struct_type_name }}.LAYOUT.byteSize()); } - void uniffiSetValue({{ ffi_struct.name()|ffi_struct_name }} other) { - {%- for field in ffi_struct.fields() %} - {{ field.name()|var_name }} = other.{{ field.name()|var_name }}; - {%- endfor %} + public static void set{{ field.name()|var_name_raw }}(java.lang.foreign.MemorySegment seg, java.lang.foreign.MemorySegment value) { + java.lang.foreign.MemorySegment.copy(value, 0, seg, OFFSET_{{ field.name()|var_name_raw|fn_name }}, {{ field.type_().borrow()|ffi_struct_type_name }}.LAYOUT.byteSize()); + } + {%- else %} + public static {{ field.type_().borrow()|ffi_type_name(config, ci) }} get{{ field.name()|var_name_raw }}(java.lang.foreign.MemorySegment seg) { + return {{ field.type_().borrow()|ffi_invoke_exact_cast }}seg.get({{ field.type_().borrow()|ffi_value_layout_unaligned }}, OFFSET_{{ field.name()|var_name_raw|fn_name }}); } + public static void set{{ field.name()|var_name_raw }}(java.lang.foreign.MemorySegment seg, {{ field.type_().borrow()|ffi_type_name(config, ci) }} value) { + seg.set({{ field.type_().borrow()|ffi_value_layout_unaligned }}, OFFSET_{{ field.name()|var_name_raw|fn_name }}, value); + } + {%- endif %} + {%- endfor %} + + /** + * Allocate a new instance in the given arena. + */ + public static java.lang.foreign.MemorySegment allocate(java.lang.foreign.SegmentAllocator allocator) { + java.lang.foreign.MemorySegment seg = allocator.allocate(LAYOUT); + seg.fill((byte) 0); + return seg; + } } {%- when FfiDefinition::Function(_) %} {#- functions are handled below #} @@ -104,53 +123,66 @@ void uniffiSetValue({{ ffi_struct.name()|ffi_struct_name }} other) { package {{ config.package_name() }}; -import com.sun.jna.Native; - -// For large crates we prevent `MethodTooLargeException` (see #2340) -// N.B. the name of the exception is very misleading, since it is -// rather `InterfaceTooLargeException`, caused by too many methods -// in the interface for large crates. -// -// By splitting the otherwise huge interface into two parts -// * UniffiLib -// * IntegrityCheckingUniffiLib -// And all checksum methods are put into `IntegrityCheckingUniffiLib` -// we allow for ~2x as many methods in the UniffiLib interface. -// -// Note: above all written when we used JNA's `loadIndirect` etc. -// We now use JNA's "direct mapping" - unclear if same considerations apply exactly. -final class IntegrityCheckingUniffiLib { +// FFM-based library binding. Each FFI function gets a MethodHandle and a wrapper method. +final class UniffiLib { + private static final java.lang.foreign.Linker LINKER = java.lang.foreign.Linker.nativeLinker(); + private static final java.lang.foreign.SymbolLookup SYMBOLS; + + {% if ci.contains_object_types() %} + // The Cleaner for the whole library + static UniffiCleaner CLEANER; + {%- endif %} + static { - Native.register(IntegrityCheckingUniffiLib.class, NamespaceLibrary.findLibraryName("{{ ci.namespace() }}")); - NamespaceLibrary.uniffiCheckContractApiVersion(); -{%- if !config.omit_checksums() %} - NamespaceLibrary.uniffiCheckApiChecksums(); -{%- endif %} + SYMBOLS = NamespaceLibrary.loadLibrary(); } - {% for func in ci.iter_ffi_function_integrity_checks() -%} - native static {% match func.return_type() %}{% when Some with (return_type) %}{{ return_type.borrow()|ffi_type_name_primitive(config, ci) }}{% when None %}void{% endmatch %} {{ func.name() }}({%- call java::arg_list_ffi_decl_primitive(func) %}); - {% endfor %} -} - -package {{ config.package_name() }}; + private static java.lang.invoke.MethodHandle findDowncallHandle(String name, java.lang.foreign.FunctionDescriptor descriptor) { + return SYMBOLS.find(name) + .map(s -> LINKER.downcallHandle(s, descriptor)) + .orElseThrow(() -> new RuntimeException("Missing FFI symbol: " + name)); + } -import com.sun.jna.Native; -import com.sun.jna.Pointer; + {% for func in ci.iter_ffi_function_definitions() -%} + // {{ func.name() }} + {%- match func.return_type() %} + {%- when Some(return_type) %} + {%- if return_type|ffi_type_is_struct %} + private static final java.lang.invoke.MethodHandle MH_{{ func.name() }} = findDowncallHandle("{{ func.name() }}", java.lang.foreign.FunctionDescriptor.of({{ return_type|ffi_value_layout }}{% for arg in func.arguments() %}, {{ arg.type_().borrow()|ffi_value_layout }}{% endfor %}{% if func.has_rust_call_status_arg() %}, java.lang.foreign.ValueLayout.ADDRESS{% endif %})); + + static java.lang.foreign.MemorySegment {{ func.name() }}(java.lang.foreign.SegmentAllocator _allocator{% for arg in func.arguments() %}, {{ arg.type_().borrow()|ffi_type_name(config, ci) }} {{ arg.name()|var_name }}{% endfor %}{% if func.has_rust_call_status_arg() %}, java.lang.foreign.MemorySegment uniffiOutErr{% endif %}) { + try { + return (java.lang.foreign.MemorySegment) MH_{{ func.name() }}.invokeExact(_allocator{% for arg in func.arguments() %}, {{ arg.name()|var_name }}{% endfor %}{% if func.has_rust_call_status_arg() %}, uniffiOutErr{% endif %}); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } + {%- else %} + private static final java.lang.invoke.MethodHandle MH_{{ func.name() }} = findDowncallHandle("{{ func.name() }}", java.lang.foreign.FunctionDescriptor.of({{ return_type|ffi_value_layout }}{% for arg in func.arguments() %}, {{ arg.type_().borrow()|ffi_value_layout }}{% endfor %}{% if func.has_rust_call_status_arg() %}, java.lang.foreign.ValueLayout.ADDRESS{% endif %})); -// A JNA Library to expose the extern-C FFI definitions. -// This is an implementation detail which will be called internally by the public API. -final class UniffiLib { - {% if ci.contains_object_types() %} - // The Cleaner for the whole library - static UniffiCleaner CLEANER; + static {{ return_type|ffi_type_name(config, ci) }} {{ func.name() }}({% for arg in func.arguments() %}{{ arg.type_().borrow()|ffi_type_name(config, ci) }} {{ arg.name()|var_name }}{% if !loop.last %}, {% endif %}{% endfor %}{% if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}java.lang.foreign.MemorySegment uniffiOutErr{% endif %}) { + try { + return {{ return_type|ffi_invoke_exact_cast }}MH_{{ func.name() }}.invokeExact({% for arg in func.arguments() %}{{ arg.name()|var_name }}{% if !loop.last %}, {% endif %}{% endfor %}{% if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}uniffiOutErr{% endif %}); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } {%- endif %} + {%- when None %} + private static final java.lang.invoke.MethodHandle MH_{{ func.name() }} = findDowncallHandle("{{ func.name() }}", java.lang.foreign.FunctionDescriptor.ofVoid({% for arg in func.arguments() %}{{ arg.type_().borrow()|ffi_value_layout }}{% if !loop.last %}, {% endif %}{% endfor %}{% if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}java.lang.foreign.ValueLayout.ADDRESS{% endif %})); + + static void {{ func.name() }}({% for arg in func.arguments() %}{{ arg.type_().borrow()|ffi_type_name(config, ci) }} {{ arg.name()|var_name }}{% if !loop.last %}, {% endif %}{% endfor %}{% if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}java.lang.foreign.MemorySegment uniffiOutErr{% endif %}) { + try { + MH_{{ func.name() }}.invokeExact({% for arg in func.arguments() %}{{ arg.name()|var_name }}{% if !loop.last %}, {% endif %}{% endfor %}{% if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}uniffiOutErr{% endif %}); + } catch (Throwable _ex) { throw new AssertionError("invokeExact failed", _ex); } + } + {%- endmatch %} + + {% endfor %} + // Integrity checks and initialization must happen after all MethodHandle fields + // are initialized (static fields are initialized in textual order in Java). static { - Native.register(UniffiLib.class, - NamespaceLibrary.findLibraryName("{{ ci.namespace() }}")); - // Force IntegrityCheckingUniffiLib to load first to run integrity checks - Class ignored = IntegrityCheckingUniffiLib.class; + NamespaceLibrary.uniffiCheckContractApiVersion(); +{%- if !config.omit_checksums() %} + NamespaceLibrary.uniffiCheckApiChecksums(); +{%- endif %} {% if ci.contains_object_types() %} CLEANER = UniffiCleaner.create(); {%- endif %} @@ -158,10 +190,6 @@ final class UniffiLib { {{ init_fn }}(); {% endfor -%} } - - {% for func in ci.iter_ffi_function_definitions_excluding_integrity_checks() -%} - native static {% match func.return_type() %}{% when Some with (return_type) %}{{ return_type.borrow()|ffi_type_name_primitive(config, ci) }}{% when None %}void{% endmatch %} {{ func.name() }}({%- call java::arg_list_ffi_decl_primitive(func) %}); - {% endfor %} } package {{ config.package_name() }}; @@ -173,13 +201,9 @@ final class UniffiLib { public final class UniffiInitializer { /** * Force initialization of the native library. - * UniffiLib is initialized as classes are used, but we still need to explicitly - * reference it so initialization across crates works as expected. */ public static void ensureInitialized() { - // Force IntegrityCheckingUniffiLib class to load (runs integrity checks) - Class ignored1 = IntegrityCheckingUniffiLib.class; - // Force UniffiLib class to load (runs initialization functions) - Class ignored2 = UniffiLib.class; + // Force UniffiLib class to load (runs integrity checks and initialization functions) + Class ignored = UniffiLib.class; } } diff --git a/src/templates/ObjectCleanerHelper.java b/src/templates/ObjectCleanerHelper.java index a4f252f..041f565 100644 --- a/src/templates/ObjectCleanerHelper.java +++ b/src/templates/ObjectCleanerHelper.java @@ -1,11 +1,10 @@ package {{ config.package_name() }}; // The cleaner interface for Object finalization code to run. -// This is the entry point to any implementation that we're using. -// -// The cleaner registers objects and returns cleanables, so now we are -// defining a `UniffiCleaner` with a `UniffiClenaer.Cleanable` to abstract the -// different implmentations available at compile time. +// Uses a custom PhantomReference-based implementation that provides backpressure: +// when objects are created faster than the background cleaner thread can process them, +// the registering thread drains pending cleanups inline, preventing OOM in +// high-throughput scenarios (e.g., benchmarks, tight loops without explicit close()). interface UniffiCleaner { interface Cleanable { void clean(); @@ -14,66 +13,122 @@ interface Cleanable { UniffiCleaner.Cleanable register(java.lang.Object value, java.lang.Runnable cleanUpTask); public static UniffiCleaner create() { - {% if config.android_cleaner() %} - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return new AndroidSystemCleaner(); - } else { - return new UniffiJnaCleaner(); - } - {%- else %} - try { - // For safety's sake: if the library hasn't been run in android_cleaner = true - // mode, but is being run on Android, then we still need to think about - // Android API versions. - // So we check if java.lang.ref.Cleaner is there, and use that… - java.lang.Class.forName("java.lang.ref.Cleaner"); - return new JavaLangRefCleaner(); - } catch (java.lang.ClassNotFoundException _e) { - // … otherwise, fallback to the JNA cleaner. - return new UniffiJnaCleaner(); - } - {%- endif %} + return new UniffiBackpressureCleaner(); } } package {{ config.package_name() }}; -import com.sun.jna.internal.Cleaner; +// A Cleaner backed by PhantomReference + ReferenceQueue with opportunistic inline draining. +// +// How it works: +// 1. Each registered object gets a PhantomReference pointing at a ReferenceQueue. +// A doubly-linked list keeps strong references to the PhantomRefs so they aren't +// GC'd before their referents. +// 2. A daemon thread blocks on queue.remove() and runs cleanup actions (steady state). +// 3. On every register() call, we do a small non-blocking drain of the queue on the +// calling thread. queue.poll() is ~nanoseconds when empty, so the overhead is +// negligible, but under sustained allocation pressure it provides continuous +// backpressure that prevents the cleanup backlog from growing unboundedly. +// 4. Explicit close()/clean() is idempotent — manual clean and GC-triggered clean +// race via a volatile CAS, so the action runs at most once. +// 5. clean() is idempotent via VarHandle CAS. It synchronizes on the list sentinel +// to unlink itself, preventing dead entries from accumulating. +class UniffiBackpressureCleaner implements UniffiCleaner { + private static final int DRAIN_BATCH_SIZE = 8; -// The fallback Jna cleaner, which is available for both Android, and the JVM. -class UniffiJnaCleaner implements UniffiCleaner { - private final Cleaner cleaner = Cleaner.getCleaner(); + private final java.lang.ref.ReferenceQueue queue = new java.lang.ref.ReferenceQueue<>(); + // Sentinel node for the doubly-linked list. All list mutations synchronize on this. + private final CleanableRef head = new CleanableRef(); + + UniffiBackpressureCleaner() { + Thread t = new Thread(() -> { + while (true) { + try { + java.lang.ref.Reference ref = queue.remove(); + if (ref instanceof CleanableRef) { + ((CleanableRef) ref).clean(); + } + } catch (InterruptedException e) { + break; + } + } + }); + t.setDaemon(true); + t.setName("uniffi-cleaner"); + t.start(); + } @Override public UniffiCleaner.Cleanable register(java.lang.Object value, java.lang.Runnable cleanUpTask) { - return new UniffiJnaCleanable(cleaner.register(value, cleanUpTask)); + // Opportunistic drain: process a few pending refs on this thread. + // queue.poll() is essentially free when the queue is empty. + for (int i = 0; i < DRAIN_BATCH_SIZE; i++) { + java.lang.ref.Reference ref = queue.poll(); + if (ref == null) break; + if (ref instanceof CleanableRef) { + ((CleanableRef) ref).clean(); + } + } + return new CleanableRef(head, value, queue, cleanUpTask); } -} -package {{ config.package_name() }}; + // A PhantomReference that doubles as a Cleanable, stored in a doubly-linked list to + // keep it alive until cleanup. The cleanup action is stored in a volatile field with + // VarHandle CAS for idempotent clean(). clean() synchronizes on the list sentinel to + // unlink, keeping the list trimmed. + private static class CleanableRef extends java.lang.ref.PhantomReference implements UniffiCleaner.Cleanable { + private static final java.lang.invoke.VarHandle ACTION; + static { + try { + ACTION = java.lang.invoke.MethodHandles.lookup() + .findVarHandle(CleanableRef.class, "action", java.lang.Runnable.class); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } -import com.sun.jna.internal.Cleaner; + private volatile java.lang.Runnable action; + private final CleanableRef list; // sentinel to synchronize on + private CleanableRef prev; + private CleanableRef next; -class UniffiJnaCleanable implements UniffiCleaner.Cleanable { - private final Cleaner.Cleanable cleanable; + // Sentinel constructor. + CleanableRef() { + super(null, null); + this.list = this; + this.prev = this; + this.next = this; + } - public UniffiJnaCleanable(Cleaner.Cleanable cleanable) { - this.cleanable = cleanable; - } + // Normal constructor — inserts itself into the list. + CleanableRef(CleanableRef list, java.lang.Object referent, java.lang.ref.ReferenceQueue q, java.lang.Runnable action) { + super(referent, q); + this.action = action; + this.list = list; + synchronized (list) { + this.prev = list; + this.next = list.next; + list.next.prev = this; + list.next = this; + } + } - @Override - public void clean() { - cleanable.clean(); + @Override + public void clean() { + // Atomic swap ensures the action runs at most once, even if called + // concurrently from close() and the background cleaner thread. + java.lang.Runnable a = (java.lang.Runnable) ACTION.getAndSet(this, null); + if (a != null) { + synchronized (list) { + // Because of the swap above this these guards shouldn't be necessary, but including them defensively + if (next != null) next.prev = prev; + if (prev != null) prev.next = next; + prev = null; + next = null; + } + a.run(); + } + } } } - -// We decide at uniffi binding generation time whether we were -// using Android or not. -// There are further runtime checks to chose the correct implementation -// of the cleaner. -{% if config.android_cleaner() %} -{%- include "ObjectCleanerHelperAndroid.java" %} -{%- else %} -{%- include "ObjectCleanerHelperJvm.java" %} -{%- endif %} - diff --git a/src/templates/ObjectTemplate.java b/src/templates/ObjectTemplate.java index 82716a2..380e1dd 100644 --- a/src/templates/ObjectTemplate.java +++ b/src/templates/ObjectTemplate.java @@ -213,7 +213,7 @@ public UniffiCleanAction(long handle) { public void run() { // If the handle is 0 this is a fake object created with `NoHandle`, don't try to free. if (handle != 0L) { - UniffiHelpers.uniffiRustCall(status -> { + UniffiHelpers.uniffiRustCall((_allocator, status) -> { UniffiLib.{{ obj.ffi_object_free().name() }}(handle, status); return null; }); @@ -222,7 +222,7 @@ public void run() { } long uniffiCloneHandle() { - return UniffiHelpers.uniffiRustCall(status -> { + return UniffiHelpers.uniffiRustCall((_allocator, status) -> { if (handle == 0L) { throw new java.lang.NullPointerException(); } @@ -248,9 +248,9 @@ long uniffiCloneHandle() { public class {{ impl_class_name }}ErrorHandler implements UniffiRustCallStatusErrorHandler<{{ impl_class_name }}> { @Override - public {{ impl_class_name }} lift(RustBuffer.ByValue error_buf) { + public {{ impl_class_name }} lift(java.lang.foreign.MemorySegment error_buf) { // Due to some mismatches in the ffi converter mechanisms, errors are a RustBuffer. - var bb = error_buf.asByteBuffer(); + var bb = RustBuffer.asByteBuffer(error_buf); if (bb == null) { throw new InternalException("?"); } diff --git a/src/templates/RustBufferTemplate.java b/src/templates/RustBufferTemplate.java index 88a3775..a4b01ce 100644 --- a/src/templates/RustBufferTemplate.java +++ b/src/templates/RustBufferTemplate.java @@ -1,69 +1,119 @@ package {{ config.package_name() }}; -import com.sun.jna.Structure; -import com.sun.jna.Pointer; - /** * This is a helper for safely working with byte buffers returned from the Rust code. * A rust-owned buffer is represented by its capacity, its current length, and a * pointer to the underlying data. */ -@Structure.FieldOrder({ "capacity", "len", "data" }) -public class RustBuffer extends Structure { - public long capacity; - public long len; - public Pointer data; - - public static class ByValue extends RustBuffer implements Structure.ByValue {} - public static class ByReference extends RustBuffer implements Structure.ByReference {} - - void setValue(RustBuffer other) { - this.capacity = other.capacity; - this.len = other.len; - this.data = other.data; +public final class RustBuffer { + public static final java.lang.foreign.StructLayout LAYOUT = java.lang.foreign.MemoryLayout.structLayout( + java.lang.foreign.ValueLayout.JAVA_LONG.withName("capacity"), + java.lang.foreign.ValueLayout.JAVA_LONG.withName("len"), + java.lang.foreign.ValueLayout.ADDRESS.withName("data") + ); + + private static final long OFFSET_CAPACITY = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("capacity")); + private static final long OFFSET_LEN = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("len")); + private static final long OFFSET_DATA = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("data")); + + private RustBuffer() {} + + public static long getCapacity(java.lang.foreign.MemorySegment seg) { + return seg.get(java.lang.foreign.ValueLayout.JAVA_LONG_UNALIGNED, OFFSET_CAPACITY); + } + + public static void setCapacity(java.lang.foreign.MemorySegment seg, long value) { + seg.set(java.lang.foreign.ValueLayout.JAVA_LONG_UNALIGNED, OFFSET_CAPACITY, value); + } + + public static long getLen(java.lang.foreign.MemorySegment seg) { + return seg.get(java.lang.foreign.ValueLayout.JAVA_LONG_UNALIGNED, OFFSET_LEN); + } + + public static void setLen(java.lang.foreign.MemorySegment seg, long value) { + seg.set(java.lang.foreign.ValueLayout.JAVA_LONG_UNALIGNED, OFFSET_LEN, value); + } + + public static java.lang.foreign.MemorySegment getData(java.lang.foreign.MemorySegment seg) { + return seg.get(java.lang.foreign.ValueLayout.ADDRESS_UNALIGNED, OFFSET_DATA); + } + + public static void setData(java.lang.foreign.MemorySegment seg, java.lang.foreign.MemorySegment value) { + seg.set(java.lang.foreign.ValueLayout.ADDRESS_UNALIGNED, OFFSET_DATA, value); } - public static RustBuffer.ByValue alloc(long size) { - RustBuffer.ByValue buffer = UniffiHelpers.uniffiRustCall((UniffiRustCallStatus status) -> { - return (RustBuffer.ByValue) UniffiLib.{{ ci.ffi_rustbuffer_alloc().name() }}(size, status); + public static java.lang.foreign.MemorySegment alloc(long size) { + java.lang.foreign.MemorySegment buffer = UniffiHelpers.uniffiRustCall((_allocator, status) -> { + return UniffiLib.{{ ci.ffi_rustbuffer_alloc().name() }}(_allocator, size, status); }); - if (buffer.data == null) { + if (getData(buffer).equals(java.lang.foreign.MemorySegment.NULL) && size > 0) { throw new java.lang.RuntimeException("RustBuffer.alloc() returned null data pointer (size=" + size + ")"); } return buffer; } - public static void free(RustBuffer.ByValue buffer) { - UniffiHelpers.uniffiRustCall((status) -> { + public static void free(java.lang.foreign.MemorySegment buffer) { + UniffiHelpers.uniffiRustCall((_allocator, status) -> { UniffiLib.{{ ci.ffi_rustbuffer_free().name() }}(buffer, status); return null; }); } - public java.nio.ByteBuffer asByteBuffer() { - if (this.data != null) { - java.nio.ByteBuffer byteBuffer = this.data.getByteBuffer(0, this.len); - byteBuffer.order(java.nio.ByteOrder.BIG_ENDIAN); - return byteBuffer; + /** + * Get a ByteBuffer view of the data for reading (len bytes). + */ + public static java.nio.ByteBuffer asByteBuffer(java.lang.foreign.MemorySegment seg) { + long len = getLen(seg); + if (len == 0) { + return java.nio.ByteBuffer.allocate(0).order(java.nio.ByteOrder.BIG_ENDIAN); } - return null; + return getData(seg).reinterpret(len).asByteBuffer().order(java.nio.ByteOrder.BIG_ENDIAN); + } + + /** + * Get a ByteBuffer view of the data for writing (capacity bytes). + */ + public static java.nio.ByteBuffer asWriteByteBuffer(java.lang.foreign.MemorySegment seg) { + long capacity = getCapacity(seg); + if (capacity == 0) { + return java.nio.ByteBuffer.allocate(0).order(java.nio.ByteOrder.BIG_ENDIAN); + } + return getData(seg).reinterpret(capacity).asByteBuffer().order(java.nio.ByteOrder.BIG_ENDIAN); } } package {{ config.package_name() }}; -import com.sun.jna.Structure; -import com.sun.jna.Pointer; - // This is a helper for safely passing byte references into the rust code. // It's not actually used at the moment, because there aren't many things that you // can take a direct pointer to in the JVM, and if we're going to copy something // then we might as well copy it into a `RustBuffer`. But it's here for API // completeness. -@Structure.FieldOrder({ "len", "data" }) -public class ForeignBytes extends Structure { - public int len; - public Pointer data; +public final class ForeignBytes { + public static final java.lang.foreign.StructLayout LAYOUT = java.lang.foreign.MemoryLayout.structLayout( + java.lang.foreign.ValueLayout.JAVA_INT.withName("len"), + java.lang.foreign.MemoryLayout.paddingLayout(4), // 4 bytes padding for alignment before ADDRESS + java.lang.foreign.ValueLayout.ADDRESS.withName("data") + ); - public static class ByValue extends ForeignBytes implements Structure.ByValue {} + private static final long OFFSET_LEN = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("len")); + private static final long OFFSET_DATA = LAYOUT.byteOffset(java.lang.foreign.MemoryLayout.PathElement.groupElement("data")); + + private ForeignBytes() {} + + public static int getLen(java.lang.foreign.MemorySegment seg) { + return seg.get(java.lang.foreign.ValueLayout.JAVA_INT_UNALIGNED, OFFSET_LEN); + } + + public static void setLen(java.lang.foreign.MemorySegment seg, int value) { + seg.set(java.lang.foreign.ValueLayout.JAVA_INT_UNALIGNED, OFFSET_LEN, value); + } + + public static java.lang.foreign.MemorySegment getData(java.lang.foreign.MemorySegment seg) { + return seg.get(java.lang.foreign.ValueLayout.ADDRESS_UNALIGNED, OFFSET_DATA); + } + + public static void setData(java.lang.foreign.MemorySegment seg, java.lang.foreign.MemorySegment value) { + seg.set(java.lang.foreign.ValueLayout.ADDRESS_UNALIGNED, OFFSET_DATA, value); + } } diff --git a/src/templates/StringHelper.java b/src/templates/StringHelper.java index bd0faf2..7ab8222 100644 --- a/src/templates/StringHelper.java +++ b/src/templates/StringHelper.java @@ -1,16 +1,16 @@ package {{ config.package_name() }}; -public enum FfiConverterString implements FfiConverter { +public enum FfiConverterString implements FfiConverter { INSTANCE; // Note: we don't inherit from FfiConverterRustBuffer, because we use a // special encoding when lowering/lifting. We can use `RustBuffer.len` to // store our length and avoid writing it out to the buffer. @Override - public java.lang.String lift(RustBuffer.ByValue value) { + public java.lang.String lift(java.lang.foreign.MemorySegment value) { try { - byte[] byteArr = new byte[(int) value.len]; - value.asByteBuffer().get(byteArr); + byte[] byteArr = new byte[(int) RustBuffer.getLen(value)]; + RustBuffer.asByteBuffer(value).get(byteArr); return new java.lang.String(byteArr, java.nio.charset.StandardCharsets.UTF_8); } finally { RustBuffer.free(value); @@ -37,12 +37,11 @@ private java.nio.ByteBuffer toUtf8(java.lang.String value) { } @Override - public RustBuffer.ByValue lower(java.lang.String value) { + public java.lang.foreign.MemorySegment lower(java.lang.String value) { java.nio.ByteBuffer byteBuf = toUtf8(value); - // Ideally we'd pass these bytes to `ffi_bytebuffer_from_bytes`, but doing so would require us - // to copy them into a JNA `Memory`. So we might as well directly copy them into a `RustBuffer`. - RustBuffer.ByValue rbuf = RustBuffer.alloc((long) byteBuf.limit()); - rbuf.asByteBuffer().put(byteBuf); + java.lang.foreign.MemorySegment rbuf = RustBuffer.alloc((long) byteBuf.limit()); + RustBuffer.asWriteByteBuffer(rbuf).put(byteBuf); + RustBuffer.setLen(rbuf, (long) byteBuf.limit()); return rbuf; } diff --git a/src/templates/macros.java b/src/templates/macros.java index 833b0b4..bf7bd03 100644 --- a/src/templates/macros.java +++ b/src/templates/macros.java @@ -24,17 +24,29 @@ {%- endmacro %} {%- macro to_raw_ffi_call(func) -%} + {%- match func.return_type() %} + {%- when Some(return_type) %} + {%- let prim_suffix = return_type|primitive_call_suffix %} + {%- let ffi_type = return_type|ffi_type %} + {%- when None %} + {%- endmatch %} {%- match func.throws_type() %} {%- when Some(e) %} {%- if e|is_external(ci) %} - UniffiHelpers.uniffiRustCallWithError(new {{ e|class_name_from_type(ci) }}ExternalErrorHandler(), + UniffiHelpers.uniffiRustCallWithError{% match func.return_type() %}{%- when Some(return_type) %}{{ return_type|primitive_call_suffix }}{% when None %}{% endmatch %}(new {{ e|class_name_from_type(ci) }}ExternalErrorHandler(), {%- else %} - UniffiHelpers.uniffiRustCallWithError(new {{ e|type_name(ci, config) }}ErrorHandler(), + UniffiHelpers.uniffiRustCallWithError{% match func.return_type() %}{%- when Some(return_type) %}{{ return_type|primitive_call_suffix }}{% when None %}{% endmatch %}(new {{ e|type_name(ci, config) }}ErrorHandler(), {%- endif %} {%- else %} - UniffiHelpers.uniffiRustCall( - {%- endmatch %} _status -> { + UniffiHelpers.uniffiRustCall{% match func.return_type() %}{%- when Some(return_type) %}{{ return_type|primitive_call_suffix }}{% when None %}{% endmatch %}( + {%- endmatch %} (_allocator, _status) -> { {% if func.return_type().is_some() %}return {% endif %}UniffiLib.{{ func.ffi_func().name() }}( + {%- match func.return_type() %} + {%- when Some(return_type) %} + {%- let ffi_type = return_type|ffi_type %} + {%- if ffi_type.borrow()|ffi_type_is_struct %}_allocator, {% endif %} + {%- when None %} + {%- endmatch %} {%- match func.self_type() %} {%- when Some with (Type::Object { .. }) %}uniffiHandle, {%- when Some(t) %}{{ t|lower_fn(config, ci) }}(this), @@ -75,7 +87,14 @@ {%- else -%} {%- endmatch %} { try { - {% match callable.return_type() -%}{%- when Some with (return_type) -%}return {{ return_type|lift_fn(config, ci) }}({% call to_ffi_call(callable) %}){%- when None %}{% call to_ffi_call(callable) %}{%- endmatch %}; + {% match callable.return_type() -%} + {%- when Some with (return_type) -%} + {%- if return_type|has_primitive_ffi_type -%} + return {% call to_ffi_call(callable) %} + {%- else -%} + return {{ return_type|lift_fn(config, ci) }}({% call to_ffi_call(callable) %}) + {%- endif -%} + {%- when None %}{% call to_ffi_call(callable) %}{%- endmatch %}; } catch (java.lang.RuntimeException _uniffi_ex) { {% match callable.throws_type() %} {% when Some(throwable) %} @@ -138,7 +157,11 @@ {%- macro arg_list_lowered(func) %} {%- for arg in func.arguments() %} + {%- if arg|has_primitive_ffi_type -%} + {{- arg.name()|var_name }} + {%- else -%} {{- arg|lower_fn(config, ci) }}({{ arg.name()|var_name }}) + {%- endif -%} {%- if !loop.last %}, {% endif -%} {%- endfor %} {%- endmacro -%} @@ -172,20 +195,9 @@ -#} {%- macro arg_list_ffi_decl(func) %} {%- for arg in func.arguments() %} - {{- arg.type_().borrow()|ffi_type_name_by_value(config, ci) }} {{arg.name()|var_name -}}{%- if !loop.last %}, {% endif -%} - {%- endfor %} - {%- if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}UniffiRustCallStatus uniffi_out_errmk{% endif %} -{%- endmacro -%} - -{#- -// Arglist for JNA direct mapping native method declarations. -// Uses primitive types instead of boxed types. --#} -{%- macro arg_list_ffi_decl_primitive(func) %} - {%- for arg in func.arguments() %} - {{- arg.type_().borrow()|ffi_type_name_primitive(config, ci) }} {{arg.name()|var_name -}}{%- if !loop.last %}, {% endif -%} + {{- arg.type_().borrow()|ffi_type_name(config, ci) }} {{arg.name()|var_name -}}{%- if !loop.last %}, {% endif -%} {%- endfor %} - {%- if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}UniffiRustCallStatus uniffi_out_errmk{% endif %} + {%- if func.has_rust_call_status_arg() %}{% if func.arguments().len() != 0 %}, {% endif %}java.lang.foreign.MemorySegment uniffi_out_errmk{% endif %} {%- endmacro -%} {% macro field_name(field, field_num) %} diff --git a/src/templates/wrapper.java b/src/templates/wrapper.java index 45714e7..e3ad334 100644 --- a/src/templates/wrapper.java +++ b/src/templates/wrapper.java @@ -19,7 +19,7 @@ {% include "HandleMap.java" %} // Contains loading, initialization code, -// and the FFI Function declarations in a com.sun.jna.Library. +// and the FFI Function declarations using Java FFM (Foreign Function & Memory API). {% include "NamespaceLibraryTemplate.java" %} // Async support diff --git a/tests/scripts/TestFixtureCoverall.java b/tests/scripts/TestFixtureCoverall.java index ef19959..13fd6b5 100644 --- a/tests/scripts/TestFixtureCoverall.java +++ b/tests/scripts/TestFixtureCoverall.java @@ -418,12 +418,18 @@ public long strongCount() { try (ThreadsafeCounter counter = new ThreadsafeCounter()) { ExecutorService executor = Executors.newFixedThreadPool(3); try { + // Latch ensures the busy-waiting thread has started before the incrementing thread runs. + var busyStarted = new java.util.concurrent.CountDownLatch(1); var busyWaiting = executor.submit(() -> { + busyStarted.countDown(); // 300 ms should be long enough for the other thread to easily finish // its loop, but not so long as to annoy the user with a slow test. counter.busyWait(300); }); Future incrementing = executor.submit(() -> { + busyStarted.await(); + // Give busyWait time to acquire the Rust-side lock on slow CI. + Thread.sleep(50); Integer count = 0; for (int n = 0; n < 100; n++) { // We expect most iterations of this loop to run concurrently with the busy-waiting thread. @@ -540,6 +546,147 @@ public List getRepairs() { assert exception != null; } + // Test reentrant error handling with thread-local RustCallStatus. + // The FFM bindings use a thread-local reusable MemorySegment for RustCallStatus. + // This test verifies that rapid alternating success/error/panic calls on the + // same thread don't corrupt error data due to premature segment reuse. + try (Coveralls coveralls = new Coveralls("test_reentrant_errors")) { + // Interleaved success/error/panic/complex-error to verify the thread-local + // RustCallStatus isn't corrupted across different outcome types. + // Only a few iterations — the bug is deterministic and panics spam stderr. + for (int i = 0; i < 5; i++) { + assert coveralls.maybeThrow(false); + try { + coveralls.maybeThrow(true); + throw new RuntimeException("Expected CoverallException"); + } catch (CoverallException.TooManyHoles e) { + assert e.getMessage().equals("The coverall has too many holes"); + } + try { + coveralls.panic("expected reentrant panic " + i); + throw new RuntimeException("Expected InternalException"); + } catch (InternalException e) { + assert e.getMessage().equals("expected reentrant panic " + i); + } + try { + coveralls.maybeThrowComplex((byte) (1 + (i % 3))); + throw new RuntimeException("Expected ComplexException"); + } catch (ComplexException.OsException e) { + assert e.code() == 10; + } catch (ComplexException.PermissionDenied e) { + assert e.reason().equals("Forbidden"); + } catch (ComplexException.UnknownException e) { + // Expected + } + } + // Higher iteration count for error-only path to cycle through all complex error + // variants with their different serialized buffer sizes. + for (int i = 0; i < 1000; i++) { + assert coveralls.maybeThrow(false); + try { + coveralls.maybeThrow(true); + throw new RuntimeException("Expected CoverallException"); + } catch (CoverallException.TooManyHoles e) { + assert e.getMessage().equals("The coverall has too many holes"); + } + try { + coveralls.maybeThrowComplex((byte) (1 + (i % 3))); + throw new RuntimeException("Expected ComplexException"); + } catch (ComplexException.OsException e) { + assert e.code() == 10; + } catch (ComplexException.PermissionDenied e) { + assert e.reason().equals("Forbidden"); + } catch (ComplexException.UnknownException e) { + // Expected + } + } + } + + // Test concurrent close() vs GC cleanup race. + // Verifies that the UniffiBackpressureCleaner's atomic CAS + linked-list + // unlink is safe when explicit close() races with GC-triggered PhantomReference cleanup. + { + int numThreads = 4; + int objectsPerThread = 5000; + ExecutorService pool = Executors.newFixedThreadPool(numThreads); + List> futures = new ArrayList<>(); + + for (int t = 0; t < numThreads; t++) { + final int threadId = t; + futures.add(pool.submit(() -> { + for (int i = 0; i < objectsPerThread; i++) { + Coveralls c = new Coveralls("race-" + threadId + "-" + i); + // Half the objects are explicitly closed, half are left for GC + if (i % 2 == 0) { + c.close(); + } + // Occasionally trigger GC to race with close() + if (i % 500 == 0) { + System.gc(); + } + } + })); + } + + for (Future f : futures) { + f.get(); + } + pool.shutdown(); + + // Wait for GC to clean up the non-closed half + for (int i = 0; i < 100; i++) { + if (Coverall.getNumAlive() <= 1L) { + break; + } + System.gc(); + Thread.sleep(100); + } + assert Coverall.getNumAlive() <= 1L + : MessageFormat.format("Concurrent close/GC race test: num alive is {0}", Coverall.getNumAlive()); + } + + // Regression test: struct return allocator must not permanently leak native memory. + // Each struct-returning FFI call (e.g., String/record/list returns) allocates a 24-byte + // RustBuffer metadata segment. With Arena.global() this leaked permanently — 100k calls + // would leak ~2.4 MB that GC could never reclaim. + // + // This test runs two 100k-call batches with GC between them and measures growth. + // With a permanent leak, the second batch adds ~2.4 MB of unreclaimable memory. + // With the slab allocator (Arena.ofAuto()-backed), exhausted slabs are GC'd and + // growth should be near zero. The 2 MB threshold is below the 2.4 MB leak signal + // but well above normal JVM heap noise between identical GC'd workloads. + { + Runtime runtime = Runtime.getRuntime(); + try (Coveralls coveralls = new Coveralls("test_no_struct_return_leak")) { + for (int i = 0; i < 1000; i++) { + coveralls.getStatus("warmup"); + } + + for (int i = 0; i < 100_000; i++) { + String result = coveralls.getStatus("iter"); + assert result.equals("status: iter") : "Unexpected result: " + result; + } + for (int i = 0; i < 5; i++) { + System.gc(); + Thread.sleep(50); + } + long usedMemory = runtime.totalMemory() - runtime.freeMemory(); + + for (int i = 0; i < 100_000; i++) { + coveralls.getStatus("iter2"); + } + for (int i = 0; i < 5; i++) { + System.gc(); + Thread.sleep(50); + } + long usedMemoryAfter = runtime.totalMemory() - runtime.freeMemory(); + + long growth = usedMemoryAfter - usedMemory; + assert growth < 2_000_000L + : MessageFormat.format("Struct return memory leak: {0} bytes growth between two 100k-call batches (expected near-zero, leak would show ~2.4 MB)", growth); + } + } + // This is from an earlier GC test; ealier, we made 1000 new objects. // By now, the GC has had time to clean up, and now we should see 0 alive. // (hah! Wishful-thinking there ;) @@ -554,8 +701,148 @@ public List getRepairs() { } assert Coverall.getNumAlive() <= 1L : MessageFormat.format("Num alive is {0}. GC/Cleaner thread has starved", Coverall.getNumAlive()); + + // High-throughput backpressure test: allocate 100k objects in a tight loop without + // calling close(). The backpressure cleaner drains refs inline during register(), + // preventing unbounded memory growth. Without backpressure this OOMs because the + // single Cleaner daemon thread can't keep up with the allocation rate. + for (int i = 0; i < 100_000; i++) { + new Coveralls("backpressure " + i); + } + for (int i = 0; i < 100; i++) { + if (Coverall.getNumAlive() <= 1L) { + break; + } + System.gc(); + Thread.sleep(100); + } + assert Coverall.getNumAlive() <= 1L : MessageFormat.format("Backpressure test: num alive is {0}. Cleaner could not keep up.", Coverall.getNumAlive()); + + // --- Multi-threaded slab allocator stress test --- + // The struct return slab allocator is thread-local. This validates thread-local + // isolation and GC reclamation of exhausted slabs under contention. + { + Runtime runtime = Runtime.getRuntime(); + int numThreads = 8; + int callsPerThread = 50_000; + + // Warmup + try (Coveralls warmup = new Coveralls("slab-warmup")) { + for (int i = 0; i < 1000; i++) { + warmup.getStatus("warmup"); + } + } + for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(50); } + long baselineMemory = runtime.totalMemory() - runtime.freeMemory(); + + ExecutorService slabPool = Executors.newFixedThreadPool(numThreads); + List> slabFutures = new ArrayList<>(); + for (int t = 0; t < numThreads; t++) { + final int threadId = t; + slabFutures.add(slabPool.submit(() -> { + try (Coveralls c = new Coveralls("slab-thread-" + threadId)) { + for (int i = 0; i < callsPerThread; i++) { + String result = c.getStatus("t" + threadId); + assert result.equals("status: t" + threadId) + : "Wrong result on thread " + threadId + " iteration " + i; + } + } + })); + } + for (Future f : slabFutures) { f.get(); } + slabPool.shutdown(); + + for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(50); } + long finalMemory = runtime.totalMemory() - runtime.freeMemory(); + + // 8 threads × 50k calls = 400k struct returns. With Arena.global() that would + // permanently leak 400k × 24 bytes ≈ 9.6 MB. With the slab allocator, exhausted + // slabs are GC'd and growth should be well under that. + long growth = finalMemory - baselineMemory; + assert growth < 5_000_000L + : MessageFormat.format("Multi-threaded slab leak: {0} bytes growth across {1} threads x {2} calls", + growth, numThreads, callsPerThread); + System.out.println(MessageFormat.format( + "multi-threaded slab allocator ({0} threads x {1} calls, {2} bytes growth) ... ok", + numThreads, callsPerThread, growth)); + } + + // --- Concurrent use-while-closing test --- + // Verifies that calling methods on an object while another thread calls close() + // results in a clean IllegalStateException, not a crash or data corruption. + { + int iterations = 1000; + int numCallers = 4; + ExecutorService racePool = Executors.newFixedThreadPool(numCallers + 1); + + for (int iter = 0; iter < iterations; iter++) { + Coveralls coveralls = new Coveralls("use-close-race-" + iter); + CyclicBarrier barrier = new CyclicBarrier(numCallers + 1); + + List> raceFutures = new ArrayList<>(); + for (int t = 0; t < numCallers; t++) { + raceFutures.add(racePool.submit(() -> { + try { barrier.await(); } catch (Exception e) { return; } + try { + coveralls.getName(); + } catch (IllegalStateException e) { + // Expected when close() beats us — object already destroyed + } + })); + } + raceFutures.add(racePool.submit(() -> { + try { barrier.await(); } catch (Exception e) { return; } + coveralls.close(); + })); + + for (Future f : raceFutures) { f.get(); } + } + + racePool.shutdown(); + + for (int i = 0; i < 100; i++) { + if (Coverall.getNumAlive() <= 1L) break; + System.gc(); + Thread.sleep(100); + } + assert Coverall.getNumAlive() <= 1L + : MessageFormat.format("Use-while-closing: {0} objects leaked", Coverall.getNumAlive()); + System.out.println("concurrent use-while-closing (1000 iterations x 5 threads) ... ok"); + } + + // --- Large RustBuffer roundtrip test --- + // Validates that megabyte-scale data passes correctly through FFM MemorySegment + // and that RustBuffer allocation/deallocation handles large sizes without leaks. + { + Runtime runtime = Runtime.getRuntime(); + try (Coveralls coveralls = new Coveralls("large-data-test")) { + String largeInput = "0123456789".repeat(100_000); // 1 MB + + coveralls.getStatus("warmup"); + for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(50); } + long baseline = runtime.totalMemory() - runtime.freeMemory(); + + for (int i = 0; i < 100; i++) { + String result = coveralls.getStatus(largeInput); + assert result.length() == "status: ".length() + largeInput.length() + : "Large data length mismatch on iteration " + i; + assert result.startsWith("status: 0123456789") + : "Large data corruption on iteration " + i; + } + + for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(50); } + long after = runtime.totalMemory() - runtime.freeMemory(); + + // 100 × ~1MB strings lower + lift = ~200 MB of RustBuffer traffic. + // If buffers leak, memory growth would be massive. Allow 10 MB for JVM noise. + long growth = after - baseline; + assert growth < 10_000_000L + : MessageFormat.format("Large RustBuffer leak: {0} bytes growth for 100 x 1MB roundtrips", growth); + } + System.out.println("large RustBuffer roundtrip (100 x 1MB) ... ok"); + } } - + public static boolean almostEquals(float a, float b) { return Math.abs(a - b) < .000001f; } diff --git a/tests/scripts/TestFixtureFutures/TestFixtureFutures.java b/tests/scripts/TestFixtureFutures/TestFixtureFutures.java index 9f8df0a..1cf69fc 100644 --- a/tests/scripts/TestFixtureFutures/TestFixtureFutures.java +++ b/tests/scripts/TestFixtureFutures/TestFixtureFutures.java @@ -37,7 +37,9 @@ public static long measureTimeMillis(FutureRunnable r) { } public static void assertReturnsImmediately(long actualTime, String testName) { - assert actualTime <= 4 : MessageFormat.format("unexpected {0} time: {1}ms", testName, actualTime); + // this is usually 5ms or less even on CI, but setting to 10 to avoid flakiness, if there's a regression in + // this it'll likely go past 10 immediately. + assert actualTime <= 10 : MessageFormat.format("unexpected {0} time: {1}ms", testName, actualTime); } public static void assertApproximateTime(long actualTime, int expectedTime, String testName) { @@ -535,9 +537,146 @@ public CompletableFuture tryDelay(String delayMs) { System.out.println("custom executor ... ok"); } + + // --- UniffiRustCallStatus.create() native memory leak test --- + // Two-batch approach: batch 1 warms up malloc's free list, batch 2 should reuse + // freed memory (no RSS growth). With a leak (Arena.global()), batch 2 always + // allocates fresh native memory, so RSS grows by ~16+ MB between batches. + { + var dummyBuf = java.lang.foreign.Arena.ofAuto().allocate( + uniffi.fixture.futures.RustBuffer.LAYOUT); + + int batchSize = 500_000; + + // Batch 1: fill malloc's free list with freed slabs + for (int i = 0; i < batchSize; i++) { + uniffi.fixture.futures.UniffiRustCallStatus.create( + uniffi.fixture.futures.UniffiRustCallStatus.UNIFFI_CALL_ERROR, + dummyBuf); + } + for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(50); } + long rssAfterBatch1 = getProcessRssKb(); + + // Batch 2: with slab, reuses freed memory; with Arena.global(), leaks ~16 MB more + for (int i = 0; i < batchSize; i++) { + uniffi.fixture.futures.UniffiRustCallStatus.create( + uniffi.fixture.futures.UniffiRustCallStatus.UNIFFI_CALL_ERROR, + dummyBuf); + } + for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(50); } + long rssAfterBatch2 = getProcessRssKb(); + + long growthKb = rssAfterBatch2 - rssAfterBatch1; + System.out.println(MessageFormat.format( + "UniffiRustCallStatus.create() RSS: batch1={0}KB batch2={1}KB delta={2}KB ({3} calls/batch)", + rssAfterBatch1, rssAfterBatch2, growthKb, batchSize)); + // With Arena.global(): 500k × ~42 bytes ≈ 21 MB delta (never freed) + // With slab: delta ≈ 0 (batch 2 reuses freed slabs from batch 1) + assert growthKb < 10_000 + : MessageFormat.format( + "create() leaked native memory: {0} KB growth between batches of {1} calls", + growthKb, batchSize); + System.out.println("create() memory test ... ok"); + } + + // --- Async callback error path stress test --- + // Exercises UniffiRustCallStatus allocation under sustained async callback errors. + // Validates no handle leaks or data corruption under rapid reuse. + { + class StressParser implements AsyncParser { + @Override + public CompletableFuture asString(int delayMs, int value) { + return CompletableFuture.completedFuture(Integer.toString(value)); + } + @Override + public CompletableFuture tryFromString(int delayMs, String value) { + CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new ParserException.NotAnInt()); + return f; + } + @Override + public CompletableFuture delay(int delayMs) { + return CompletableFuture.completedFuture(null); + } + @Override + public CompletableFuture tryDelay(String delayMs) { + CompletableFuture f = new CompletableFuture<>(); + f.completeExceptionally(new ParserException.NotAnInt()); + return f; + } + } + + var stressParser = new StressParser(); + int errorCount = 10_000; + + for (int i = 0; i < errorCount; i++) { + try { + Futures.tryFromStringUsingTrait(stressParser, 0, "not-a-number").get(); + throw new RuntimeException("Expected error on iteration " + i); + } catch (ExecutionException e) { + assert e.getCause() instanceof ParserException.NotAnInt + : "Wrong exception type on iteration " + i + ": " + e.getCause(); + } + } + + var handleCount = UniffiAsyncHelpers.uniffiForeignFutureHandleCount(); + assert handleCount == 0 + : MessageFormat.format("Leaked {0} handles after {1} async errors", handleCount, errorCount); + + // Verify system still works after sustained errors + var check = Futures.asStringUsingTrait(stressParser, 0, 99).get(); + assert check.equals("99") : "System broken after error stress: " + check; + + System.out.println(MessageFormat.format("async error path stress ({0} errors) ... ok", errorCount)); + } + + // --- Async callback sustained success load test --- + // Each async trait callback allocates Arena.ofAuto() for the result struct. + // Validates no handle leaks or OOM under sustained successful calls. + { + class LoadParser implements AsyncParser { + @Override + public CompletableFuture asString(int delayMs, int value) { + return CompletableFuture.completedFuture(Integer.toString(value)); + } + @Override + public CompletableFuture tryFromString(int delayMs, String value) { + return CompletableFuture.completedFuture(Integer.parseInt(value)); + } + @Override + public CompletableFuture delay(int delayMs) { + return CompletableFuture.completedFuture(null); + } + @Override + public CompletableFuture tryDelay(String delayMs) { + return CompletableFuture.completedFuture(null); + } + } + + var loadParser = new LoadParser(); + int count = 5_000; + + for (int i = 0; i < count; i++) { + var result = Futures.asStringUsingTrait(loadParser, 0, i).get(); + assert result.equals(Integer.toString(i)) + : "Wrong result on iteration " + i + ": " + result; + } + + var handleCount = UniffiAsyncHelpers.uniffiForeignFutureHandleCount(); + assert handleCount == 0 + : MessageFormat.format("Leaked {0} handles after {1} async calls", handleCount, count); + + System.out.println(MessageFormat.format("async callback sustained load ({0} calls) ... ok", count)); + } } finally { // bring down the scheduler, if it's not shut down it'll hold the main thread open. scheduler.shutdown(); } } + + private static long getProcessRssKb() throws Exception { + long pid = ProcessHandle.current().pid(); + Process p = new ProcessBuilder("/bin/ps", "-o", "rss=", "-p", String.valueOf(pid)).start(); + return Long.parseLong(new String(p.getInputStream().readAllBytes()).trim()); + } } diff --git a/tests/scripts/TestSprites.java b/tests/scripts/TestSprites.java new file mode 100644 index 0000000..281698c --- /dev/null +++ b/tests/scripts/TestSprites.java @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import uniffi.sprites.*; + +public class TestSprites { + public static void main(String[] args) throws Exception { + // Test default position (null -> 0,0) + try (var sempty = new Sprite((Point) null)) { + assert sempty.getPosition().x() == 0.0; + assert sempty.getPosition().y() == 0.0; + } + + // Test initial position + try (var s = new Sprite(new Point(0.0, 1.0))) { + assert s.getPosition().x() == 0.0; + assert s.getPosition().y() == 1.0; + + // Test moveTo + s.moveTo(new Point(1.0, 2.0)); + assert s.getPosition().x() == 1.0; + assert s.getPosition().y() == 2.0; + + // Test moveBy + s.moveBy(new Vector(-4.0, 2.0)); + assert s.getPosition().x() == -3.0; + assert s.getPosition().y() == 4.0; + } + + // Test use-after-close + { + var s = new Sprite(new Point(0.0, 0.0)); + s.close(); + try { + s.moveBy(new Vector(0.0, 0.0)); + throw new RuntimeException("Should not be able to call after close"); + } catch (IllegalStateException e) { + // expected + } + } + + // Test alternative constructor + try (var srel = Sprite.newRelativeTo(new Point(0.0, 1.0), new Vector(1.0, 1.5))) { + assert srel.getPosition().x() == 1.0; + assert srel.getPosition().y() == 2.5; + } + + // Test namespace function + Point translated = Sprites.translate(new Point(1.0, 2.0), new Vector(3.0, 4.0)); + assert translated.x() == 4.0; + assert translated.y() == 6.0; + } +} diff --git a/tests/scripts/TestTodolist.java b/tests/scripts/TestTodolist.java new file mode 100644 index 0000000..b989df2 --- /dev/null +++ b/tests/scripts/TestTodolist.java @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import uniffi.todolist.*; + +import java.util.List; + +public class TestTodolist { + public static void main(String[] args) throws Exception { + try (var todo = new TodoList()) { + // Empty list should throw + try { + todo.getLast(); + throw new RuntimeException("Should have thrown EmptyTodoList"); + } catch (TodoException.EmptyTodoList e) { + // expected + } + + // Empty string should throw + try { + Todolist.createEntryWith(""); + throw new RuntimeException("Should have thrown EmptyString"); + } catch (TodoException.EmptyString e) { + // expected + } + + todo.addItem("Write strings support"); + assert todo.getLast().equals("Write strings support"); + + todo.addItem("Write tests for strings support"); + assert todo.getLast().equals("Write tests for strings support"); + + TodoEntry entry = Todolist.createEntryWith("Write bindings for strings as record members"); + todo.addEntry(entry); + assert todo.getLast().equals("Write bindings for strings as record members"); + assert todo.getLastEntry().text().equals("Write bindings for strings as record members"); + + // Unicode handling + todo.addItem("Test unicode handling without an entry"); + assert todo.getLast().equals("Test unicode handling without an entry"); + + TodoEntry entry2 = new TodoEntry("Test unicode handling in an entry"); + todo.addEntry(entry2); + assert todo.getLastEntry().text().equals("Test unicode handling in an entry"); + + assert todo.getEntries().size() == 5; + + todo.addEntries(List.of(new TodoEntry("foo"), new TodoEntry("bar"))); + assert todo.getEntries().size() == 7; + assert todo.getLastEntry().text().equals("bar"); + + todo.addItems(List.of("bobo", "fofo")); + assert todo.getItems().size() == 9; + assert todo.getItems().get(7).equals("bobo"); + + assert Todolist.getDefaultList() == null; + + // Test global default list + try (var todo2 = new TodoList()) { + Todolist.setDefaultList(todo); + try (var defaultList = Todolist.getDefaultList()) { + assert defaultList != null; + assert defaultList.getLast().equals("fofo"); + } + + todo2.makeDefault(); + try (var defaultList = Todolist.getDefaultList()) { + assert defaultList != null; + assert defaultList.getItems().isEmpty(); + } + } + + // Duplicate detection + try (var todo3 = new TodoList()) { + todo3.addItem("foo"); + try { + todo3.addItem("foo"); + throw new RuntimeException("Should have thrown DuplicateTodo"); + } catch (TodoException.DuplicateTodo e) { + // expected + } + } + } + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 82b1ece..a8d0499 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -5,7 +5,6 @@ License, v. 2.0. If a copy of the MPL was not distributed with this use anyhow::{Context, Result, bail}; use camino::{Utf8Path, Utf8PathBuf}; use cargo_metadata::{CrateType, MetadataCommand, Package, Target}; -use std::env::consts::ARCH; use std::io::{Read, Write}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; @@ -76,17 +75,29 @@ fn run_test(fixture_name: &str, test_file: &str) -> Result<()> { }, )?; - // jna requires a specific resources path inside the jar by default, create that folder - let cdylib_java_resource_folder = if cdylib_path.extension().unwrap() == "dylib" { - format!("darwin-{}", ARCH).replace("_", "-") - } else { - format!("linux-{}", ARCH).replace("_", "-") - }; - let cdylib_java_resource_path = out_dir.join("staging").join(cdylib_java_resource_folder); - fs::create_dir_all(&cdylib_java_resource_path)?; - let cdylib_dest = cdylib_java_resource_path.join(cdylib_path.file_name().unwrap()); + // Copy the cdylib to a flat directory for java.library.path. + // System.loadLibrary expects "lib.dylib" (macOS) or "lib.so" (Linux). + // The cdylib from cargo has a hash suffix, so we create a symlink with the + // expected name that System.loadLibrary will find. + let native_lib_dir = out_dir.join("native"); + fs::create_dir_all(&native_lib_dir)?; + let cdylib_filename = cdylib_path.file_name().unwrap(); + let cdylib_dest = native_lib_dir.join(cdylib_filename); fs::copy(&cdylib_path, &cdylib_dest)?; + let extension = cdylib_path.extension().unwrap(); // "dylib" or "so" + let lib_base_name = cdylib_filename + .strip_prefix("lib") + .unwrap_or(cdylib_filename) + .split('-') + .next() + .unwrap_or(cdylib_filename); + let expected_lib_name = format!("lib{}.{}", lib_base_name, extension); + let symlink_path = native_lib_dir.join(&expected_lib_name); + if !symlink_path.exists() { + std::os::unix::fs::symlink(cdylib_dest.file_name().unwrap(), &symlink_path)?; + } + // compile generated bindings and form jar let jar_file = build_jar(fixture_name, &out_dir)?; @@ -110,6 +121,10 @@ fn run_test(fixture_name: &str, test_file: &str) -> Result<()> { let run_status = Command::new("java") // allow for runtime assertions .arg("-ea") + // Enable FFM native access + .arg("--enable-native-access=ALL-UNNAMED") + // Set native library path so System.loadLibrary can find the cdylib + .arg(format!("-Djava.library.path={}", native_lib_dir)) .arg("-classpath") .arg(calc_classpath(vec![ &out_dir, @@ -172,7 +187,6 @@ fn build_jar(fixture_name: &str, out_dir: &Utf8PathBuf) -> Result { .arg("-d") .arg(&staging_dir) .arg("-classpath") - // JNA must already be in the system classpath .arg(calc_classpath(vec![])) .args( glob::glob(&out_dir.join("**/*.java").into_string())? @@ -256,12 +270,13 @@ fixture_tests! { (test_arithmetic, "uniffi-example-arithmetic", "scripts/TestArithmetic.java"), (test_geometry, "uniffi-example-geometry", "scripts/TestGeometry.java"), (test_rondpoint, "uniffi-example-rondpoint", "scripts/TestRondpoint.java"), - // (test_todolist, "uniffi-example-todolist", "scripts/test_todolist.java"), - // (test_sprites, "uniffi-example-sprites", "scripts/test_sprites.java"), + // todolist: namespace class `Todolist` and object class `TodoList` produce filenames that + // collide on case-insensitive filesystems (macOS). + // (test_todolist, "uniffi-example-todolist", "scripts/TestTodolist.java"), + (test_sprites, "uniffi-example-sprites", "scripts/TestSprites.java"), (test_coverall, "uniffi-fixture-coverall", "scripts/TestFixtureCoverall.java"), (test_chronological, "uniffi-fixture-time", "scripts/TestChronological.java"), (test_custom_types, "uniffi-example-custom-types", "scripts/TestCustomTypes/TestCustomTypes.java"), - // (test_callbacks, "uniffi-fixture-callbacks", "scripts/test_callbacks.java"), (test_external_types, "uniffi-fixture-ext-types", "scripts/TestImportedTypes/TestImportedTypes.java"), (test_futures, "uniffi-example-futures", "scripts/TestFutures.java"), (test_futures_fixtures, "uniffi-fixture-futures", "scripts/TestFixtureFutures/TestFixtureFutures.java"),