From 3c9ec72b882e42548a5373f204e27e0cfe9cc495 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sun, 4 Oct 2026 15:01:16 -0700 Subject: [PATCH] add nix flake/fix release mode tests --- .forgejo/workflows/build.yaml | 16 +++++++++- .gitignore | 4 +++ README.md | 49 +++++++++++++++++++++++++----- engine/src/jni.zig | 20 +++++++----- flake.lock | 26 ++++++++++++++++ flake.nix | 46 ++++++++++++++++++++++++++++ nix/package.nix | 57 +++++++++++++++++++++++++++++++++++ 7 files changed, 201 insertions(+), 17 deletions(-) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/package.nix diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index 64f40ec..40df8a1 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -55,13 +55,27 @@ jobs: # Engine, CLI, TUI, the C ABI from Zig, the JNI layer against a synthetic # function table, and the C ABI from C through the installed header. run: zig build test --summary all + - name: Run tests in each release mode + # What ships is not Debug: the CLI and TUI are ReleaseSafe, the Android library + # ReleaseSmall. Debug fills undefined memory with 0xaa, so code that reads + # something it never wrote can pass there by luck and fail in a release build: + # the JNI tests did exactly that. ReleaseFast has no safety checks at all, which + # is where such a read shows up most readily, so it runs too. + run: | + for mode in ReleaseSafe ReleaseSmall ReleaseFast; do + echo "== $mode" + zig build test -Doptimize="$mode" --summary all + done - name: Setup Java uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' - name: Prove the JNI table against a real JVM - run: zig build jvm-test --summary all + # Debug, and ReleaseSmall: the optimization the Android library ships with. + run: | + zig build jvm-test --summary all + zig build jvm-test -Doptimize=ReleaseSmall --summary all - name: Package the CLI and TUI # One binary holds both. Static musl on Linux, so it runs on any distribution. run: | diff --git a/.gitignore b/.gitignore index 1298275..a2bc9b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ .zig-cache/ zig-out/ +# nix build output links +result +result-* + # Gradle and Android build artifacts android/.gradle/ android/.kotlin/ diff --git a/README.md b/README.md index 6acaf57..2128c5c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,35 @@ Android ties an app to its signing key permanently, so a published APK cannot up copy you built yourself (which is signed with your debug key), and the reverse. Uninstall one before installing the other; the app's saved state goes with it. +### With nix + +This repository is a flake, for the CLI and TUI (the Android app stays with gradle). To +add it to a `buildEnv`-style home profile: + +```nix +inputs = { + tally.url = "git+https://git.lerch.org/lobo/tally.git"; + # Optional: reuse your own nixpkgs instead of instantiating another one. + tally.inputs.nixpkgs.follows = "nixpkgs"; +}; +``` + +then add `tally.packages.${system}.default` to your package list. Or run it directly: + +``` +nix run git+https://git.lerch.org/lobo/tally.git -- '2^100 + 1' +``` + +The package is the `tally` binary, plus the engine as `lib/libtally-engine.a`, +`lib/libtally.so` and `include/tally.h`. `nix flake update tally` picks up a new version. +The build runs `zig build test` in the sandbox, in ReleaseSafe like the binary it +installs, so a version that fails its tests will not install. `nix develop` gives a shell +with Zig 0.16 and zls. + +Changing the dependencies in `build.zig.zon` invalidates the `zigDeps` hash in +`nix/package.nix`. To refresh it: set the hash to `lib.fakeHash`, build, and paste the +hash nix reports. + ## Building Every tool comes from [mise](https://mise.jdx.dev), pinned in [.mise.toml](.mise.toml): @@ -186,9 +215,13 @@ A few decisions shape everything else, and the design document explains each: - `zig build test`: about a thousand Zig tests across the engine, CLI, TUI (rendering real frames and reading the cells back), the C ABI, and the JNI layer against a synthetic function table; plus a C program that links the shared library through - `tally.h`, which catches a header that disagrees with the library. + `tally.h`, which catches a header that disagrees with the library. CI also runs it with + `-Doptimize=ReleaseSafe`, `ReleaseSmall` and `ReleaseFast`: what ships is not Debug, + and Debug's 0xaa fill of undefined memory can hide a read of something never written + (it hid one in the JNI tests). - `zig build jvm-test`: the JNI entry points from Java, which is the only way to check - the function-table indices without a device. + the function-table indices without a device. CI runs it in Debug and in ReleaseSmall, + the optimization the Android library ships with. - `cd android && gradle testDebugUnitTest`: JVM tests of the app's rules (the converter, programmer mode, saved state), no device needed. - `cd android && gradle connectedAndroidTest`: the engine through the real app on a @@ -203,12 +236,12 @@ punctuation: the source is ASCII. [.forgejo/workflows/build.yaml](.forgejo/workflows/build.yaml) runs on every push: -| Job | Runner | What | -|---------------------|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| Engine, CLI and TUI | `ubuntu-latest` | `zig fmt --check`, zlint, build, `zig build test`, `zig build jvm-test`, then release builds for x86_64 and aarch64 Linux and aarch64 macOS | -| Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) | -| Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore | -| Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` | +| Job | Runner | What | +|---------------------|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Engine, CLI and TUI | `ubuntu-latest` | `zig fmt --check`, zlint, build, `zig build test` in Debug and each release mode, `zig build jvm-test` in Debug and ReleaseSmall, then release builds for x86_64 and aarch64 Linux and aarch64 macOS | +| Android | `ubuntu-latest` | `zig build android`, the library audit, the JVM unit tests, debug and unsigned release APKs (with lint's release checks) | +| Sign | `ubuntu-latest-with-hsm` | master only: signs the APK with the HSM, then every release file with a detached signature logged to sigstore | +| Publish | `ubuntu-latest` | master only: uploads the signed files to the generic package registry under the short SHA and `latest` | Every job reports to ntfy. Zlint and Gradle are downloaded at pinned versions and checked against pinned SHA-256 digests; Zig's version comes from `build.zig.zon`. diff --git a/engine/src/jni.zig b/engine/src/jni.zig index 09fbb7f..ec84c8c 100644 --- a/engine/src/jni.zig +++ b/engine/src/jni.zig @@ -508,20 +508,24 @@ const FakeJvm = struct { var current: ?*FakeJvm = null; + /// Puts every field in its starting state, defaults included: the tests declare the + /// fake `undefined` and call this, so a field set nowhere here would be whatever the + /// stack held. It was: `misplace_new_string` was never reset, Debug's 0xaa fill + /// happened to read as false, and release builds, which do not fill, failed the JNI + /// tests whenever the leftover byte was odd. fn init(self: *FakeJvm) void { - self.table = .{ .entries = @splat(null) }; + self.* = .{ + .table = .{ .entries = @splat(null) }, + .vm_table = .{ .entries = @splat(null) }, + // Addresses inside `self`, which is where this value is being written. + .env = .{ .functions = &self.table }, + .vm = .{ .functions = &self.vm_table }, + }; 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; } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8b87fb9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1791142733, + "narHash": "sha256-x1jawtfPqMTQYpt1d6H+9rxXweqTzU5hYhdfz0T5MPM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "466eaf945dddfd32abd006bd94cc55660fdb56c8", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "master", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..359e990 --- /dev/null +++ b/flake.nix @@ -0,0 +1,46 @@ +{ + description = "Calculator with exact arithmetic, unit conversion and a programmer mode, as a CLI and TUI"; + + inputs.nixpkgs.url = "nixpkgs/master"; + + outputs = { self, nixpkgs }: + let + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + + # Helper to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'. + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + tally = pkgs.callPackage ./nix/package.nix { + # Pinned deliberately rather than tracking `pkgs.zig`: build.zig.zon + # declares 0.16.0 as the minimum, and 0.16 was a large standard + # library refactor, so a newer default would not build unchanged. + zig = pkgs.zig_0_16; + # The git-tracked tree, which keeps .zig-cache, zig-out, zig-pkg and + # the Android build outputs out of the derivation. + src = self; + }; + in + { + inherit tally; + default = tally; + }); + + devShells = forAllSystems (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in + { + # `nix develop` for the CLI/TUI without mise. The versions here are the + # same ones .mise.toml pins; the Android toolchain stays with mise. + default = pkgs.mkShell { + packages = [ + pkgs.zig_0_16 + pkgs.zls + ]; + }; + }); + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..5bdb4f7 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,57 @@ +{ lib, stdenv, zig, src }: + +stdenv.mkDerivation (finalAttrs: { + pname = "tally"; + # Kept in step with build.zig.zon. + version = "0.1.0"; + + inherit src; + + __structuredAttrs = true; + strictDeps = true; + + # Zig resolves build.zig.zon dependencies (libvaxis) over the network, which the + # build sandbox forbids. `fetchDeps` runs `zig build --fetch` inside a fixed-output + # derivation, so the fetch happens once against a known hash and the real build + # stays offline. + # + # This hash changes whenever build.zig.zon's dependencies change. To update it: set + # `lib.fakeHash`, run the build, and paste the hash nix reports. + zigDeps = zig.fetchDeps { + inherit (finalAttrs) pname version src; + fetchAll = true; + hash = "sha256-Wy/H3z2dBsF/WTF2LrCofOuE15pbgdwEJLSUrKCOQGI="; + }; + + postConfigure = '' + # A writable copy rather than a symlink into the store: Zig writes cache + # metadata alongside the fetched packages while it verifies them. + cp -rLT ${finalAttrs.zigDeps} "$ZIG_GLOBAL_CACHE_DIR/p" + chmod -R u+w "$ZIG_GLOBAL_CACHE_DIR/p" + ''; + + nativeBuildInputs = [ zig ]; + + # The zig setup hook's check phase runs `zig build test`, with the same flags as the + # build (ReleaseSafe): the engine, CLI, TUI, C ABI and JNI tests, and the C program + # linked through tally.h. None of them touch the network or anything outside the + # build directory, so every rebuild is a test run. (`jvm-test`, `coverage` and + # `android` are separate steps, not run here.) + doCheck = true; + + meta = { + description = "Calculator with exact arithmetic, unit conversion and a programmer mode, as a CLI and TUI"; + longDescription = '' + One Zig engine behind a command line, a terminal UI and an Android app: + exact rational arithmetic where the operations allow it, programmer mode + (8 to 128-bit integers in every base), unit conversion as part of the + expression language, and financial functions. This package is the CLI/TUI + binary, plus the engine as a static library and as a shared library with + its C header (tally.h). + ''; + homepage = "https://git.lerch.org/lobo/tally"; + license = lib.licenses.mit; + mainProgram = "tally"; + platforms = lib.platforms.unix; + }; +})