java shims and tests

This commit is contained in:
Emil Lerch 2026-10-03 12:21:02 -07:00
parent e819a8d0bf
commit 23c73f8513
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 1089 additions and 0 deletions

783
engine/src/jni.zig Normal file
View file

@ -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));
}

169
engine/test/c_abi_test.c Normal file
View file

@ -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 <stdio.h>
#include <string.h>
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;
}

View file

@ -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);
}
}