From 23c73f8513e0cd3a46de2e04a9a912bdaa2e43d8 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 3 Oct 2026 12:21:02 -0700 Subject: [PATCH] java shims and tests --- engine/src/jni.zig | 783 ++++++++++++++++++ engine/test/c_abi_test.c | 169 ++++ .../java/dev/lerch/tally/TallyEngine.java | 137 +++ 3 files changed, 1089 insertions(+) create mode 100644 engine/src/jni.zig create mode 100644 engine/test/c_abi_test.c create mode 100644 engine/test/java/dev/lerch/tally/TallyEngine.java diff --git a/engine/src/jni.zig b/engine/src/jni.zig new file mode 100644 index 0000000..d39ac0d --- /dev/null +++ b/engine/src/jni.zig @@ -0,0 +1,783 @@ +//! JNI entry points for the Android app. Compiled only for Android targets. +//! +//! This is the one Android-specific layer (design 6.3). It is deliberately thin: +//! convert a `jstring` to bytes, call the C ABI, copy the answer into a Java string, +//! return. Everything below it is `c_api.zig`, which knows nothing about Java. +//! +//! That copy is also what makes the C ABI's borrowed-result rule safe here: the bytes +//! are turned into a JVM object inside the same function that asked for them, so the +//! app never holds a native pointer and cannot outlive one. +//! +//! ## Why the function table is written out by hand +//! +//! JNI passes a `JNIEnv*`, which is a pointer to a table of function pointers at +//! fixed indices. Reaching them normally means including `jni.h` from the NDK. We do +//! not: the library is built with no NDK and no libc at all (design 6.2), and pulling +//! in a sysroot for three functions would undo that. +//! +//! The price is that four integers below have to be right, and a wrong one is a jump +//! into the wrong function rather than a compile error. Three things hold that down: +//! +//! 1. The table is declared as its full published length. `table_len` is asserted at +//! comptime, so an index past the end cannot compile. +//! 2. `JNI_OnLoad` proves the indices against the real JVM before anything else runs: +//! it checks the version, then round-trips a string through `NewStringUTF` and +//! `GetStringUTFChars` and compares the bytes. If the table is not what this file +//! thinks it is, `System.loadLibrary` fails at load with `JNI_ERR` instead of the +//! app crashing later inside an evaluation. +//! 3. The tests at the bottom run every entry point against a synthetic table, which +//! catches the marshalling and the self-check logic. What they cannot catch is a +//! transcription error in the indices themselves - only a real JVM can - which is +//! exactly what point 2 exists for. + +const std = @import("std"); +const c_api = @import("c_api.zig"); + +// -- Std configuration -- + +/// Answers "how big is a page?" without asking the operating system. +/// +/// `std.heap.page_allocator` rounds every request up to `std.heap.pageSize()`. Where +/// Zig's minimum and maximum for a target are equal that is a comptime constant, but on +/// aarch64 the range is 4 KiB to 64 KiB, so it becomes a runtime query - and the way std +/// asks is `getauxval`, which lives in a libc this library does not link. The result was +/// an undefined `getauxval` on arm64-v8a only, with an eagerly bound PLT entry +/// (`BIND_NOW`) that fails `dlopen` on the one ABI most phones actually run. x86_64 and +/// armeabi-v7a were clean, so an emulator alone would not have caught it. +/// +/// Only the ceiling and the query are overridden, deliberately. `page_size_min` is what +/// std documents as the alignment a caller may assume from a page allocation, and on a +/// 4 KiB kernel `mmap` returns 4 KiB-aligned memory: raising the minimum would promise an +/// alignment the kernel does not give. Raising only the maximum, and answering the query +/// with it, affects the direction that is safe - lengths get rounded up to a multiple of +/// the real page size, never down. +/// +/// 16 KiB is the honest ceiling for current Android rather than a guess: Android 15 +/// introduced 16 KiB pages and the platform requires libraries to be aligned for them, +/// which is the same number `link_z_max_page_size` uses in `build.zig`. The cost of +/// assuming it on a 4 KiB device is granularity on allocations the session's arena is +/// already batching. +pub const std_options: std.Options = .{ + .page_size_max = 16 * 1024, + .queryPageSize = androidPageSize, +}; + +fn androidPageSize() usize { + return 16 * 1024; +} + +// -- Panic -- + +/// Replaces std's default panic handler, for this library only. +/// +/// The default handler is what pulled the last two Bionic symbols into this image, and +/// it did so on a path that only a bug would ever reach. It prints a stack trace, which +/// reads the library's own ELF headers through `getauxval`, and it guards against +/// recursive panics with a thread-local, which needs `__tls_get_addr`. Neither is +/// defined here and nothing is linked that defines them, so `dlopen` fails with +/// "cannot locate symbol __tls_get_addr" at load - before `JNI_OnLoad`, before any +/// calculation, on a device that is working perfectly. A debugging aid cost the whole +/// library its ability to load. +/// +/// Writing the message to stderr and trapping gives up nothing on Android. The trap +/// raises a fatal signal, and the platform's crash handler writes a tombstone with a +/// native backtrace of its own, which is a better trace than std would have printed +/// here and is produced by code that is already resident. +/// +/// Only the Android build sees this: it is the one that uses this file as its root, and +/// a panic decl is read from the root. The tests below run under the test runner's own +/// root and keep the default handler, so a failing assertion still reports normally. +pub const panic = std.debug.FullPanic(panicTrap); + +fn panicTrap(msg: []const u8, first_trace_addr: ?usize) noreturn { + _ = first_trace_addr; + // Best effort by design: a closed or redirected stderr is not worth handling on the + // way down, and the tombstone carries the failure regardless. + const prefix = "tally panic: "; + _ = std.os.linux.write(2, prefix.ptr, prefix.len); + _ = std.os.linux.write(2, msg.ptr, msg.len); + _ = std.os.linux.write(2, "\n", 1); + @trap(); +} + +// -- JNI types -- +// +// The subset this file touches. `jobject` and friends are opaque handles; nothing here +// dereferences one. + +const jint = i32; +const jlong = i64; +const jboolean = u8; +const jobject = ?*anyopaque; +const jstring = jobject; + +const jni_version_1_6: jint = 0x00010006; +const jni_err: jint = -1; + +/// Entries in `JNINativeInterface`, which has had this length since JNI 1.6. +const table_len = 233; + +/// The four we call, by index into that table. +/// +/// Derived from the function table in the JNI specification, where the order is +/// normative: the four reserved slots, then `GetVersion`, through the class, exception, +/// reference, call, field and static-field groups, to the string group at 163. Within +/// it: `NewString` 163, `GetStringLength` 164, `GetStringChars` 165, +/// `ReleaseStringChars` 166, `NewStringUTF` 167, `GetStringUTFLength` 168, +/// `GetStringUTFChars` 169, `ReleaseStringUTFChars` 170. The count arrives at exactly +/// `table_len` entries with `GetObjectRefType` last, which is the arithmetic check that +/// nothing was dropped along the way. +const EnvIndex = enum(usize) { + get_version = 4, + new_string_utf = 167, + get_string_utf_chars = 169, + release_string_utf_chars = 170, +}; + +/// `JavaVM`'s table is short enough to be uncontroversial: three reserved slots, then +/// `DestroyJavaVM`, `AttachCurrentThread`, `DetachCurrentThread`, `GetEnv`, +/// `AttachCurrentThreadAsDaemon`. +const VmIndex = enum(usize) { + get_env = 6, +}; + +const vm_table_len = 8; + +comptime { + for (std.enums.values(EnvIndex)) |index| { + if (@intFromEnum(index) >= table_len) @compileError("JNI index past the end of the table"); + } + for (std.enums.values(VmIndex)) |index| { + if (@intFromEnum(index) >= vm_table_len) @compileError("JavaVM index past the end of the table"); + } +} + +/// `JNINativeInterface`: a table of function pointers, reached by index because the +/// names are not available without the NDK's headers. +pub const FunctionTable = extern struct { + entries: [table_len]?*const anyopaque, +}; + +/// `JNIEnv`, which C declares as a pointer to the table. A `JNIEnv*` parameter is +/// therefore a pointer to this. +pub const Env = extern struct { + functions: *const FunctionTable, + + fn call(self: *Env, comptime index: EnvIndex, comptime Signature: type) Signature { + const slot = self.functions.entries[@intFromEnum(index)] orelse + @panic("JNI function table has a null entry where a function belongs"); + return @ptrCast(@alignCast(slot)); + } + + fn getVersion(self: *Env) jint { + return self.call(.get_version, *const fn (*Env) callconv(.c) jint)(self); + } + + /// A Java string from NUL-terminated modified UTF-8. Every byte this library + /// produces is ASCII - digits, unit names, the error phrases - and ASCII is + /// identical in both encodings, so the distinction never arises here. + fn newStringUtf(self: *Env, bytes: [*:0]const u8) jstring { + return self.call( + .new_string_utf, + *const fn (*Env, [*:0]const u8) callconv(.c) jstring, + )(self, bytes); + } + + fn getStringUtfChars(self: *Env, string: jstring) ?[*:0]const u8 { + return self.call( + .get_string_utf_chars, + *const fn (*Env, jstring, ?*jboolean) callconv(.c) ?[*:0]const u8, + )(self, string, null); + } + + fn releaseStringUtfChars(self: *Env, string: jstring, chars: [*:0]const u8) void { + self.call( + .release_string_utf_chars, + *const fn (*Env, jstring, [*:0]const u8) callconv(.c) void, + )(self, string, chars); + } +}; + +/// `JavaVM`, the same shape: a pointer to its own table. +pub const JavaVm = extern struct { + functions: *const VmTable, + + pub const VmTable = extern struct { + entries: [vm_table_len]?*const anyopaque, + }; + + fn getEnv(self: *JavaVm, out: **Env, version: jint) jint { + const slot = self.functions.entries[@intFromEnum(VmIndex.get_env)] orelse return jni_err; + const get_env: *const fn (*JavaVm, **Env, jint) callconv(.c) jint = @ptrCast(@alignCast(slot)); + return get_env(self, out, version); + } +}; + +// -- Entry points -- +// +// Kotlin declares these in `object TallyEngine`, so each takes the instance as its +// second argument. Nothing here uses it. +// +// The session travels as a `jlong`. On a 32-bit ABI a pointer is narrower than the +// handle, which is why the conversions go through `usize` rather than casting straight +// across. + +fn sessionFromHandle(handle: jlong) ?*c_api.Session { + if (handle == 0) return null; + return @ptrFromInt(@as(usize, @intCast(handle))); +} + +fn handleFromSession(session: *c_api.Session) jlong { + return @intCast(@intFromPtr(session)); +} + +export fn Java_dev_lerch_tally_TallyEngine_sessionNew(env: *Env, this: jobject) callconv(.c) jlong { + _ = env; + _ = this; + const session = c_api.tally_session_new() orelse return 0; + return handleFromSession(session); +} + +export fn Java_dev_lerch_tally_TallyEngine_sessionFree(env: *Env, this: jobject, handle: jlong) callconv(.c) void { + _ = env; + _ = this; + c_api.tally_session_free(sessionFromHandle(handle)); +} + +/// Evaluate and return the result JSON. A null return means the call could not be made +/// at all (no session, or the JVM would not give us the expression); an evaluation +/// failure is JSON describing itself, exactly as through the C ABI. +export fn Java_dev_lerch_tally_TallyEngine_eval( + env: *Env, + this: jobject, + handle: jlong, + expr: jstring, + mode: jint, +) callconv(.c) jstring { + _ = this; + return evaluate(c_api.tally_eval, env, handle, expr, mode); +} + +/// What `eval` would return, with nothing stored in the session (design 6.5). Called on +/// every keystroke by the live result. +export fn Java_dev_lerch_tally_TallyEngine_preview( + env: *Env, + this: jobject, + handle: jlong, + expr: jstring, + mode: jint, +) callconv(.c) jstring { + _ = this; + return evaluate(c_api.tally_preview, env, handle, expr, mode); +} + +/// The marshalling `eval` and `preview` share. The C entry point is a comptime +/// parameter, so each export is a direct call rather than an indirect one. +fn evaluate( + comptime entry: fn (?*c_api.Session, ?[*]const u8, usize, c_int, ?*[*]const u8, ?*usize) callconv(.c) c_api.Status, + env: *Env, + handle: jlong, + expr: jstring, + mode: jint, +) jstring { + const session = sessionFromHandle(handle) orelse return null; + const chars = env.getStringUtfChars(expr) orelse return null; + defer env.releaseStringUtfChars(expr, chars); + + // SAFETY: written by every call that gets past its argument checks. A refused call + // may leave them alone, which is why the status is checked before they are read. + var out_ptr: [*]const u8 = undefined; + var out_len: usize = 0; + const status = entry( + session, + chars, + std.mem.len(chars), + mode, + &out_ptr, + &out_len, + ); + return resultString(env, status, out_ptr); +} + +/// A result as a Java string, or null when there is none to give: out of memory, or a +/// call refused before it ran (an unknown mode, say). Kotlin reads null as "the engine +/// could not answer", which is true of both; what it must never get is the previous +/// call's JSON, or a string read from a pointer nothing wrote. +fn resultString(env: *Env, status: c_api.Status, out_ptr: [*]const u8) jstring { + switch (status) { + // `c_api` leaves a NUL after the JSON for exactly this, so the copy the JVM + // makes is the only copy in the call. + .ok, .eval_error => return env.newStringUtf(@ptrCast(out_ptr)), + .out_of_memory, .invalid_argument => return null, + } +} + +export fn Java_dev_lerch_tally_TallyEngine_unitCatalog( + env: *Env, + this: jobject, + handle: jlong, +) callconv(.c) jstring { + _ = this; + const session = sessionFromHandle(handle) orelse return null; + + // SAFETY: as in `evaluate`; read only once the status says they were written. + var out_ptr: [*]const u8 = undefined; + var out_len: usize = 0; + const status = c_api.tally_unit_catalog(session, &out_ptr, &out_len); + return resultString(env, status, out_ptr); +} + +/// A value in every unit of `unit`'s category, as JSON (design 6.6). Null when the call +/// could not be made at all; a bad value or unit is JSON describing itself. +export fn Java_dev_lerch_tally_TallyEngine_convert( + env: *Env, + this: jobject, + handle: jlong, + value: jstring, + unit: jstring, +) callconv(.c) jstring { + _ = this; + const session = sessionFromHandle(handle) orelse return null; + const value_chars = env.getStringUtfChars(value) orelse return null; + defer env.releaseStringUtfChars(value, value_chars); + const unit_chars = env.getStringUtfChars(unit) orelse return null; + defer env.releaseStringUtfChars(unit, unit_chars); + + // SAFETY: as in `evaluate`; read only once the status says they were written. + var out_ptr: [*]const u8 = undefined; + var out_len: usize = 0; + const status = c_api.tally_convert( + session, + value_chars, + std.mem.len(value_chars), + unit_chars, + std.mem.len(unit_chars), + &out_ptr, + &out_len, + ); + return resultString(env, status, out_ptr); +} + +export fn Java_dev_lerch_tally_TallyEngine_versionString(env: *Env, this: jobject) callconv(.c) jstring { + _ = this; + var len: usize = 0; + const version = c_api.tally_version(&len); + return env.newStringUtf(@ptrCast(version)); +} + +export fn Java_dev_lerch_tally_TallyEngine_abiVersion(env: *Env, this: jobject) callconv(.c) jint { + _ = env; + _ = this; + return @intCast(c_api.tally_abi_version()); +} + +/// The session's variables and `Ans` as JSON, for the app to keep across a restart. Null +/// when the call could not be made at all. +export fn Java_dev_lerch_tally_TallyEngine_sessionSave(env: *Env, this: jobject, handle: jlong) callconv(.c) jstring { + _ = this; + const session = sessionFromHandle(handle) orelse return null; + // SAFETY: as in `evaluate`; read only once the status says they were written. + var out_ptr: [*]const u8 = undefined; + var out_len: usize = 0; + if (c_api.tally_session_save(session, &out_ptr, &out_len) != .ok) return null; + return env.newStringUtf(@ptrCast(out_ptr)); +} + +/// Put a saved state back. Returns the `tally_status`; anything but ok leaves the +/// session as it was. +export fn Java_dev_lerch_tally_TallyEngine_sessionLoad( + env: *Env, + this: jobject, + handle: jlong, + state: jstring, +) callconv(.c) jint { + _ = this; + const invalid: jint = @intFromEnum(c_api.Status.invalid_argument); + const session = sessionFromHandle(handle) orelse return invalid; + const chars = env.getStringUtfChars(state) orelse return invalid; + defer env.releaseStringUtfChars(state, chars); + return @intFromEnum(c_api.tally_session_load(session, chars, std.mem.len(chars))); +} + +/// Set how many digits a session renders, leaving everything else in its configuration +/// as it was. A calculator wants every digit of `2^100`; a converter's list of a dozen +/// units does not want twenty decimals on each. Returns the `tally_status`. +export fn Java_dev_lerch_tally_TallyEngine_configureDisplay( + env: *Env, + this: jobject, + handle: jlong, + fraction_digits: jint, + significant_digits: jint, +) callconv(.c) jint { + _ = env; + _ = this; + const session = sessionFromHandle(handle) orelse return @intFromEnum(c_api.Status.invalid_argument); + if (fraction_digits < 0 or fraction_digits > std.math.maxInt(u16) or + significant_digits < 1 or significant_digits > std.math.maxInt(u16)) + { + return @intFromEnum(c_api.Status.invalid_argument); + } + var config: c_api.Config = .{}; + _ = c_api.tally_session_config(session, &config); + config.fraction_digits = @intCast(fraction_digits); + config.significant_digits = @intCast(significant_digits); + return @intFromEnum(c_api.tally_session_configure(session, &config)); +} + +/// Called by the runtime when the library loads. +/// +/// This is where the hand-written table is proved against the JVM that is actually +/// running. Failing here surfaces as an exception from `System.loadLibrary`, which is +/// the best available outcome if the indices are wrong: loud, immediate, and nowhere +/// near a user's expression. +export fn JNI_OnLoad(vm: *JavaVm, reserved: ?*anyopaque) callconv(.c) jint { + _ = reserved; + // SAFETY: `getEnv` writes this, and its return value is checked before it is read. + var env: *Env = undefined; + if (vm.getEnv(&env, jni_version_1_6) != 0) return jni_err; + if (!selfCheck(env)) return jni_err; + return jni_version_1_6; +} + +/// Prove the three string functions are where this file says they are. +/// +/// A version that looks like a JNI version, then a string round trip: build one from +/// known bytes, read it back, compare. Wrong indices will usually crash rather than +/// return the wrong answer, and that is fine - the point is that it happens here, in a +/// function whose name says what it was doing. +fn selfCheck(env: *Env) bool { + if (env.getVersion() < jni_version_1_6) return false; + + const probe = "tally"; + const made = env.newStringUtf(probe) orelse return false; + const read_back = env.getStringUtfChars(made) orelse return false; + defer env.releaseStringUtfChars(made, read_back); + return std.mem.orderZ(u8, read_back, probe) == .eq; +} + +// -- Tests -- +// +// A synthetic function table stands in for the JVM: entries at the indices this file +// claims, backed by implementations that record what they were asked. That covers the +// marshalling, the handle arithmetic, the NUL-terminated hand-off from `c_api`, and the +// self-check in both directions. It cannot verify the indices against a real JVM, which +// is what `JNI_OnLoad` is for. + +const testing = std.testing; + +/// A fake JVM, holding the strings it was asked to make. +const FakeJvm = struct { + table: FunctionTable, + vm_table: JavaVm.VmTable, + env: Env, + vm: JavaVm, + /// The last string `NewStringUTF` was given, NUL-terminated as the JVM would see it. + made: [4096]u8 = @splat(0), + made_len: usize = 0, + /// What `GetStringUTFChars` hands out, i.e. the expression coming from Kotlin. + supplied: [:0]const u8 = "", + releases: usize = 0, + /// Set to break the table the way a wrong index would. + misplace_new_string: bool = false, + + var current: ?*FakeJvm = null; + + fn init(self: *FakeJvm) void { + self.table = .{ .entries = @splat(null) }; + self.table.entries[@intFromEnum(EnvIndex.get_version)] = @ptrCast(&getVersion); + self.table.entries[@intFromEnum(EnvIndex.new_string_utf)] = @ptrCast(&newStringUtf); + self.table.entries[@intFromEnum(EnvIndex.get_string_utf_chars)] = @ptrCast(&getStringUtfChars); + self.table.entries[@intFromEnum(EnvIndex.release_string_utf_chars)] = @ptrCast(&releaseStringUtfChars); + self.env = .{ .functions = &self.table }; + + self.vm_table = .{ .entries = @splat(null) }; + self.vm_table.entries[@intFromEnum(VmIndex.get_env)] = @ptrCast(&getEnv); + self.vm = .{ .functions = &self.vm_table }; + + self.made_len = 0; + self.releases = 0; + current = self; + } + + fn madeString(self: *const FakeJvm) []const u8 { + return self.made[0..self.made_len]; + } + + fn getVersion(env: *Env) callconv(.c) jint { + _ = env; + return jni_version_1_6; + } + + fn newStringUtf(env: *Env, bytes: [*:0]const u8) callconv(.c) jstring { + _ = env; + const self = current.?; + if (self.misplace_new_string) return null; + const text = std.mem.span(bytes); + const n = @min(text.len, self.made.len - 1); + @memcpy(self.made[0..n], text[0..n]); + self.made[n] = 0; + self.made_len = n; + // A JVM returns an opaque handle; the identity is all this test needs. + return @ptrCast(self); + } + + fn getStringUtfChars(env: *Env, string: jstring, is_copy: ?*jboolean) callconv(.c) ?[*:0]const u8 { + _ = env; + if (is_copy) |flag| flag.* = 1; + const self = current.?; + // Two callers, distinguished the way a JVM would distinguish them: by object. + // `newStringUtf` hands back this fake's own address, so a string made here reads + // back as what went in (which is what the self-check relies on), while the + // expression coming from Kotlin is a different object. + if (string != null) return @ptrCast(self.made[0..self.made_len :0].ptr); + return self.supplied.ptr; + } + + fn releaseStringUtfChars(env: *Env, string: jstring, chars: [*:0]const u8) callconv(.c) void { + _ = env; + _ = string; + _ = chars; + current.?.releases += 1; + } + + fn getEnv(vm: *JavaVm, out: **Env, version: jint) callconv(.c) jint { + _ = version; + const self = current.?; + _ = vm; + out.* = &self.env; + return 0; + } +}; + +test "the table this file declares is the length the JNI specification gives it" { + // A dropped or duplicated entry would move everything after it, so the length is + // the one cheap check available without a JVM. + try testing.expectEqual(@as(usize, 233), table_len); + try testing.expectEqual(table_len * @sizeOf(usize), @sizeOf(FunctionTable)); + try testing.expectEqual(vm_table_len * @sizeOf(usize), @sizeOf(JavaVm.VmTable)); + // And `JNIEnv*` has to be a pointer to a pointer to the table, not to the table. + try testing.expectEqual(@sizeOf(usize), @sizeOf(Env)); +} + +test "an evaluation crosses JNI and comes back as JSON" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + try testing.expect(handle != 0); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + jvm.supplied = "2 + 2"; + const result = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(result != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"4\"") != null); + // The expression handed over by the JVM was released, once. + try testing.expectEqual(@as(usize, 1), jvm.releases); +} + +test "a session survives across calls, which is the whole reason for the handle" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + jvm.supplied = "x = 21"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + jvm.supplied = "x * 2"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"42\"") != null); + + jvm.supplied = "Ans + 1"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"43\"") != null); +} + +test "programmer mode reaches through, and an error comes back as JSON rather than null" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + jvm.supplied = "0xFF and 0x0F"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 1); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"dec_unsigned\":\"15\"") != null); + + jvm.supplied = "1 / 0"; + const result = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(result != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "division by zero") != null); +} + +test "the catalogue and the version strings cross too" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + _ = Java_dev_lerch_tally_TallyEngine_unitCatalog(&jvm.env, null, handle); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"base_unit\":\"m\"") != null); + + _ = Java_dev_lerch_tally_TallyEngine_versionString(&jvm.env, null); + try testing.expectEqualStrings("0.1.0", jvm.madeString()); + try testing.expectEqual(@as(jint, 5), Java_dev_lerch_tally_TallyEngine_abiVersion(&jvm.env, null)); +} + +test "a preview crosses JNI and leaves the session as it found it" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + jvm.supplied = "x = 6 * 7"; + try testing.expect(Java_dev_lerch_tally_TallyEngine_preview(&jvm.env, null, handle, null, 0) != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"42\"") != null); + try testing.expectEqual(@as(usize, 1), jvm.releases); + + jvm.supplied = "x"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "unknown variable") != null); + + try testing.expect(Java_dev_lerch_tally_TallyEngine_preview(&jvm.env, null, 0, null, 0) == null); +} + +test "a conversion crosses JNI with two strings in and one out" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + // The fake tells its strings apart by object: null is "from Kotlin", a string it + // made reads back as what it made. Two different inputs need one of each. + const unit = jvm.env.newStringUtf("C"); + jvm.supplied = "100"; + try testing.expect(Java_dev_lerch_tally_TallyEngine_convert(&jvm.env, null, handle, null, unit) != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "{\"unit\":\"F\",\"display\":\"212\"") != null); + // Both strings were released. + try testing.expectEqual(@as(usize, 2), jvm.releases); + + try testing.expect(Java_dev_lerch_tally_TallyEngine_convert(&jvm.env, null, 0, null, unit) == null); +} + +test "a session saved through JNI loads back into another" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const first = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, first); + jvm.supplied = "x = 1/3"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, first, null, 0); + + // What Kotlin would hold: the string the JVM made from the saved bytes. + const saved = Java_dev_lerch_tally_TallyEngine_sessionSave(&jvm.env, null, first); + try testing.expect(saved != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"exact\":\"1/3\"") != null); + + const second = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, second); + // A string the fake made reads back as what it made, which is the saved state. + const ok = @intFromEnum(c_api.Status.ok); + try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_sessionLoad(&jvm.env, null, second, saved)); + jvm.supplied = "x * 3"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, second, null, 0); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"1\",\"exact\":true") != null); + + const refused = @intFromEnum(c_api.Status.invalid_argument); + try testing.expect(Java_dev_lerch_tally_TallyEngine_sessionSave(&jvm.env, null, 0) == null); + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_sessionLoad(&jvm.env, null, 0, saved)); + // The expression slot (null) reads back as `supplied`, which is not a saved state. + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_sessionLoad(&jvm.env, null, second, null)); +} + +test "no result crosses JNI when there is none: out of memory, or a refused call" { + var jvm: FakeJvm = undefined; + jvm.init(); + + // A session built here rather than by `sessionNew`, so its allocator can fail. + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + var session: c_api.Session = .init(failing.allocator()); + defer session.deinit(); + const handle = handleFromSession(&session); + + jvm.supplied = "6 * 7"; + try testing.expect(Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0) != null); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"42\"") != null); + + // Out of memory at each point in turn: null every time, never the 42 again. At + // least one point has to fail, or the loop proved nothing. + var failures: usize = 0; + var point: usize = 0; + while (true) : (point += 1) { + failing.fail_index = failing.alloc_index + point; + jvm.made_len = 0; + if (Java_dev_lerch_tally_TallyEngine_unitCatalog(&jvm.env, null, handle)) |made| { + // A result, and a real one: the catalogue, not an empty string standing in. + _ = made; + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"categories\"") != null); + break; + } + failures += 1; + try testing.expectEqual(@as(usize, 0), jvm.made_len); + } + try testing.expect(failures > 0); + failing.fail_index = std.math.maxInt(usize); + + // An unknown mode is refused before anything is written. This used to read the + // out-parameters anyway, which nothing had set. + jvm.made_len = 0; + try testing.expect(Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 99) == null); + try testing.expect(Java_dev_lerch_tally_TallyEngine_preview(&jvm.env, null, handle, null, 99) == null); + try testing.expectEqual(@as(usize, 0), jvm.made_len); +} + +test "a session's display budget can be narrowed, and a nonsense one is refused" { + var jvm: FakeJvm = undefined; + jvm.init(); + + const handle = Java_dev_lerch_tally_TallyEngine_sessionNew(&jvm.env, null); + defer Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, handle); + + const ok = @intFromEnum(c_api.Status.ok); + const refused = @intFromEnum(c_api.Status.invalid_argument); + try testing.expectEqual(ok, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 4, 17)); + jvm.supplied = "2 / 3"; + _ = Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, handle, null, 0); + try testing.expect(std.mem.indexOf(u8, jvm.madeString(), "\"display\":\"0.6667\"") != null); + + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, -1, 17)); + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 4, 0)); + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, handle, 70000, 17)); + try testing.expectEqual(refused, Java_dev_lerch_tally_TallyEngine_configureDisplay(&jvm.env, null, 0, 4, 17)); +} + +test "a call with no session is refused instead of dereferencing zero" { + var jvm: FakeJvm = undefined; + jvm.init(); + + jvm.supplied = "2 + 2"; + try testing.expect(Java_dev_lerch_tally_TallyEngine_eval(&jvm.env, null, 0, null, 0) == null); + try testing.expect(Java_dev_lerch_tally_TallyEngine_unitCatalog(&jvm.env, null, 0) == null); + // Freeing nothing is allowed, so a ViewModel's teardown needs no null check. + Java_dev_lerch_tally_TallyEngine_sessionFree(&jvm.env, null, 0); +} + +test "JNI_OnLoad accepts a table that behaves and rejects one that does not" { + var jvm: FakeJvm = undefined; + jvm.init(); + try testing.expectEqual(jni_version_1_6, JNI_OnLoad(&jvm.vm, null)); + + // A table where the string functions are not what this file thinks they are. On a + // real JVM that is a crash; the point of the check is that it happens at load. + jvm.init(); + jvm.misplace_new_string = true; + try testing.expectEqual(jni_err, JNI_OnLoad(&jvm.vm, null)); + + // And a JavaVM that will not hand over an environment. + jvm.init(); + jvm.vm_table.entries[@intFromEnum(VmIndex.get_env)] = null; + try testing.expectEqual(jni_err, JNI_OnLoad(&jvm.vm, null)); +} diff --git a/engine/test/c_abi_test.c b/engine/test/c_abi_test.c new file mode 100644 index 0000000..45c49c4 --- /dev/null +++ b/engine/test/c_abi_test.c @@ -0,0 +1,169 @@ +/* Calls the C ABI the way a JNI or Swift binding will: through include/tally.h, + * linked against the real library. + * + * The Zig tests in c_api.zig cover behaviour and are leak-checked. This covers what + * they cannot: that the header matches the library, that the symbols are findable by a + * C linker, and that the struct layouts agree across the language boundary. A wrong + * field order in tally.h is invisible from Zig and fatal in the field. + */ + +#include "tally.h" + +#include +#include + +static int failures = 0; + +static void check(int condition, const char *what) { + if (!condition) { + printf("FAIL: %s\n", what); + failures++; + } +} + +static void check_contains(const uint8_t *json, size_t len, const char *needle, const char *what) { + /* The JSON is not null-terminated: it is a borrowed (ptr, len). */ + char buf[4096]; + size_t n = len < sizeof(buf) - 1 ? len : sizeof(buf) - 1; + memcpy(buf, json, n); + buf[n] = '\0'; + if (strstr(buf, needle) == NULL) { + printf("FAIL: %s\n wanted: %s\n got: %s\n", what, needle, buf); + failures++; + } +} + +int main(void) { + check(tally_abi_version() == TALLY_ABI_VERSION, "header and library agree on the ABI version"); + + size_t version_len = 0; + const uint8_t *version = tally_version(&version_len); + check(version_len == 5 && memcmp(version, "0.1.0", 5) == 0, "tally_version"); + + tally_session *session = tally_session_new(); + check(session != NULL, "tally_session_new"); + if (session == NULL) return 1; + + const uint8_t *json = NULL; + size_t len = 0; + + /* A session that remembers: the whole reason for a handle. */ + const char *assign = "x = 2^100"; + check(tally_eval(session, (const uint8_t *)assign, strlen(assign), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, + "assignment evaluates"); + + const char *use = "x + 1"; + check(tally_eval(session, (const uint8_t *)use, strlen(use), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, + "the variable survives into the next call"); + check_contains(json, len, "1,267,650,600,228,229,401,496,703,205,377", + "exact 128-bit arithmetic crosses the boundary intact"); + + const char *ans = "Ans / 2"; + check(tally_eval(session, (const uint8_t *)ans, strlen(ans), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "Ans is readable"); + check_contains(json, len, "633,825,300,114,114,700,748,351,602,688.5", + "half of an odd number, so the exact tier is really exact"); + + /* A preview answers and stores nothing: neither the variable nor Ans. */ + const char *peek = "y = 41 + 1"; + check(tally_preview(session, (const uint8_t *)peek, strlen(peek), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "preview evaluates"); + check_contains(json, len, "\"display\":\"42\"", "a preview carries the answer"); + const char *y = "y"; + check(tally_eval(session, (const uint8_t *)y, strlen(y), + TALLY_MODE_STANDARD, &json, &len) == TALLY_EVAL_ERROR, + "a previewed assignment stored nothing"); + const char *same = "Ans"; + check(tally_eval(session, (const uint8_t *)same, strlen(same), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "Ans after a preview"); + check_contains(json, len, "633,825,300,114,114,700,748,351,602,688.5", + "Ans is still the last committed answer"); + + /* An error is a status plus the engine's own wording. */ + const char *bad = "1 / 0"; + check(tally_eval(session, (const uint8_t *)bad, strlen(bad), + TALLY_MODE_STANDARD, &json, &len) == TALLY_EVAL_ERROR, + "division by zero is an eval error"); + check_contains(json, len, "division by zero", "the error carries the engine's phrase"); + + /* Unit conversion, from standard mode, with no separate entry point. */ + const char *convert = "100 km to mi"; + check(tally_eval(session, (const uint8_t *)convert, strlen(convert), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "conversion evaluates"); + check_contains(json, len, "\"from\":\"km\"", "the conversion names its units"); + + /* The config struct crosses the boundary with its fields where the header says. */ + tally_config config; + check(tally_session_config(session, &config) == TALLY_OK, "reading the config"); + check(config.bits == 64, "default width is 64"); + config.bits = 8; + config.is_signed = 1; + check(tally_session_configure(session, &config) == TALLY_OK, "configuring 8-bit signed"); + + const char *ff = "0xFF"; + check(tally_eval(session, (const uint8_t *)ff, strlen(ff), + TALLY_MODE_PROGRAMMER, &json, &len) == TALLY_OK, "programmer mode"); + check_contains(json, len, "\"dec_signed\":\"-1\"", + "0xFF read as 8-bit signed is -1, so bits and is_signed both landed"); + check_contains(json, len, "\"bits\":8", "the width reached the result"); + + config.bits = 24; + check(tally_session_configure(session, &config) == TALLY_INVALID_ARGUMENT, + "a width the engine has no name for is refused"); + + /* The catalogue a picker is built from. */ + check(tally_unit_catalog(session, &json, &len) == TALLY_OK, "unit catalogue"); + check_contains(json, len, "\"base_unit\":\"m\"", "length's base unit"); + check_contains(json, len, "kilometer", "aliases come along for searching"); + /* check_contains reads the first 4KB; length is the first category. */ + check_contains(json, len, "{\"name\":\"km\",\"label\":\"Kilometer\"", "units carry a display name"); + + /* One value in every unit of its category, for a converter screen. */ + const char *hundred = "100"; + const char *celsius = "C"; + check(tally_convert(session, (const uint8_t *)hundred, strlen(hundred), + (const uint8_t *)celsius, strlen(celsius), &json, &len) == TALLY_OK, + "tally_convert"); + check_contains(json, len, "{\"unit\":\"F\",\"display\":\"212\"", "100 C is 212 F"); + check_contains(json, len, "{\"unit\":\"K\",\"display\":\"373.15\"", "and 373.15 K"); + + /* Save one session and load it into another: x and Ans come across exactly. */ + const char *keep = "x = 2^100"; + check(tally_eval(session, (const uint8_t *)keep, strlen(keep), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "assign before save"); + check(tally_session_save(session, &json, &len) == TALLY_OK, "tally_session_save"); + check_contains(json, len, "\"name\":\"x\",\"value\":{\"exact\":\"1267650600228229401496703205376\"}", + "an exact value is saved with every digit"); + tally_session *restored = tally_session_new(); + check(restored != NULL, "a second session"); + if (restored != NULL) { + /* The saved bytes belong to the first session, so they are still valid here. */ + check(tally_session_load(restored, json, len) == TALLY_OK, "tally_session_load"); + const char *use_x = "x + 1"; + check(tally_eval(restored, (const uint8_t *)use_x, strlen(use_x), + TALLY_MODE_STANDARD, &json, &len) == TALLY_OK, "x after load"); + check_contains(json, len, "1,267,650,600,228,229,401,496,703,205,377", + "the loaded variable is the saved one"); + const char *junk = "{\"format\":1}"; + check(tally_session_load(restored, (const uint8_t *)junk, strlen(junk)) == TALLY_INVALID_ARGUMENT, + "a malformed state is refused"); + tally_session_free(restored); + } + + /* Null handling, since a cleanup path should not need its own checks. */ + check(tally_eval(NULL, (const uint8_t *)use, strlen(use), + TALLY_MODE_STANDARD, &json, &len) == TALLY_INVALID_ARGUMENT, + "a null session is refused"); + tally_session_free(NULL); + + tally_session_free(session); + + if (failures == 0) { + printf("c_abi_test: all checks passed\n"); + return 0; + } + printf("c_abi_test: %d check(s) failed\n", failures); + return 1; +} diff --git a/engine/test/java/dev/lerch/tally/TallyEngine.java b/engine/test/java/dev/lerch/tally/TallyEngine.java new file mode 100644 index 0000000..4505d73 --- /dev/null +++ b/engine/test/java/dev/lerch/tally/TallyEngine.java @@ -0,0 +1,137 @@ +package dev.lerch.tally; + +/** + * The JNI layer, tested against a real JVM on the host. + * + * `engine/src/jni.zig` reaches the JNI function table by index, because the library is + * built without the NDK and so without `jni.h`. Four of those indices have to be right, + * and a wrong one is a jump into the wrong function rather than a compile error. The Zig + * tests cover the marshalling against a synthetic table; they cannot check the indices, + * because they supply the table themselves. + * + * A JVM can. JNI's function table is fixed by the specification rather than by the + * platform, so a desktop JVM resolves the same indices an Android runtime does: if this + * passes here, the numbers are right there too. That turns the one thing said to need a + * device into something `zig build jvm-test` answers in a second. + * + * This class is deliberately a copy of the Kotlin `TallyEngine`'s native declarations + * rather than the real thing: compiling Kotlin would drag Gradle and the Android SDK into + * a test whose whole point is that it needs neither. + */ +public final class TallyEngine { + static { + // Runs JNI_OnLoad, which checks the version and round-trips a string through + // NewStringUTF and GetStringUTFChars. A wrong index fails here, loudly. + System.loadLibrary("tally"); + } + + private static native long sessionNew(); + + private static native void sessionFree(long handle); + + private static native String eval(long handle, String expr, int mode); + + private static native String preview(long handle, String expr, int mode); + + private static native String unitCatalog(long handle); + + private static native String convert(long handle, String value, String unit); + + private static native int configureDisplay(long handle, int fractionDigits, int significantDigits); + + private static native String sessionSave(long handle); + + private static native int sessionLoad(long handle, String state); + + private static native String versionString(); + + private static native int abiVersion(); + + private static int failures = 0; + + private static void check(boolean condition, String what) { + if (!condition) { + System.out.println("FAIL: " + what); + failures++; + } + } + + private static void checkContains(String haystack, String needle, String what) { + if (haystack == null || !haystack.contains(needle)) { + System.out.println("FAIL: " + what + "\n wanted: " + needle + "\n got: " + haystack); + failures++; + } + } + + public static void main(String[] args) { + check(abiVersion() == 5, "ABI version"); + check("0.1.0".equals(versionString()), "version string crosses as a Java string"); + + long session = sessionNew(); + check(session != 0, "session created"); + + // A session that remembers, which is the reason the handle exists. + checkContains(eval(session, "x = 2^100", 0), "\"ok\":true", "assignment"); + checkContains( + eval(session, "x + 1", 0), + "1,267,650,600,228,229,401,496,703,205,377", + "exact 128-bit arithmetic through JNI"); + checkContains( + eval(session, "Ans / 2", 0), + "633,825,300,114,114,700,748,351,602,688.5", + "Ans, and half of an odd number staying exact"); + + // A preview answers and stores nothing, which is what a live result needs. + checkContains(preview(session, "y = 40 + 2", 0), "\"display\":\"42\"", "preview answers"); + checkContains(eval(session, "y", 0), "unknown variable", "a previewed assignment stored nothing"); + checkContains(preview(session, "Ans * 0", 0), "\"display\":\"0\"", "preview reads Ans"); + checkContains( + eval(session, "Ans", 0), + "633,825,300,114,114,700,748,351,602,688.5", + "Ans unchanged by a preview"); + check(preview(0, "1", 0) == null, "a preview with no session is refused"); + + // An error is JSON with the engine's own wording, not an exception. + checkContains(eval(session, "1 / 0", 0), "division by zero", "error wording"); + checkContains(eval(session, "2 * 3", 0), "\"display\":\"6\"", "session still usable after an error"); + + // Unit conversion from the one field, and programmer mode. + checkContains(eval(session, "100 km to mi", 0), "\"from\":\"km\"", "conversion"); + checkContains(eval(session, "0xFF and 0x0F", 1), "\"dec_unsigned\":\"15\"", "programmer mode"); + checkContains(unitCatalog(session), "\"base_unit\":\"m\"", "units catalogue"); + checkContains(unitCatalog(session), "\"label\":\"Meter per second\"", "units carry a display name"); + checkContains(convert(session, "100", "C"), "{\"unit\":\"F\",\"display\":\"212\"", "convert, two strings in"); + checkContains(convert(session, "1", "smoot"), "unknown unit", "convert reports an unknown unit"); + check(convert(0, "1", "m") == null, "a conversion with no session is refused"); + check(configureDisplay(session, 4, 17) == 0, "configureDisplay, two ints in"); + checkContains(eval(session, "2 / 3", 0), "\"display\":\"0.6667\"", "the display budget took"); + check(configureDisplay(session, -1, 17) == 3, "a negative budget is refused"); + + // Save one session, load it into another: the variable comes across exactly. + checkContains(eval(session, "saved = 1/3", 0), "\"ok\":true", "assign before save"); + String state = sessionSave(session); + checkContains(state, "{\"name\":\"saved\",\"value\":{\"exact\":\"1/3\"}}", "save, one string out"); + long other = sessionNew(); + check(sessionLoad(other, state) == 0, "load, a string in"); + checkContains(eval(other, "saved * 3", 0), "\"display\":\"1\",\"exact\":true", "loaded exactly"); + check(sessionLoad(other, "{}") == 3, "a malformed state is refused"); + sessionFree(other); + + // A long string, to make sure nothing assumed a small buffer across the boundary. + String catalog = unitCatalog(session); + check(catalog != null && catalog.length() > 4096, "the catalogue is large and survives whole"); + + // No session is refused rather than dereferenced. + check(eval(0, "2 + 2", 0) == null, "a null session is refused"); + sessionFree(0); + + sessionFree(session); + + if (failures == 0) { + System.out.println("jvm jni test: all checks passed"); + return; + } + System.out.println("jvm jni test: " + failures + " check(s) failed"); + System.exit(1); + } +}