diff --git a/build.zig b/build.zig index 32879f2..d5f5bb5 100644 --- a/build.zig +++ b/build.zig @@ -142,6 +142,9 @@ pub fn build(b: *std.Build) void { }); mod.linkSystemLibrary("android", .{}); mod.linkSystemLibrary("log", .{}); + // `dlsym` (crash_guard's runtime lookup of labelle-bgfx's frame + // counter, labelle-android#28). + mod.linkSystemLibrary("dl", .{}); // `aaudio.zig` (the AAudio output device, phase 1c) is pure `extern fn` // against libaaudio (API 26+); the link propagates to any consumer // module that imports `labelle_android`. diff --git a/src/crash_guard.zig b/src/crash_guard.zig index 1e85509..893835f 100644 --- a/src/crash_guard.zig +++ b/src/crash_guard.zig @@ -1,25 +1,1590 @@ -//! Vulkan crash guard (labelle-bgfx#172 D11): the SEAM only. +//! Vulkan crash guard (labelle-android#28; labelle-bgfx#172 D11). //! -//! D11: mark "Vulkan start in progress" in the app's internal storage before a -//! Vulkan init, clear it once the game has run stably, and start on GLES at -//! the next launch if the mark is still there. That work is -//! labelle-toolkit/labelle-android#28. Until it lands this is a stub that -//! never disables Vulkan, so `renderer.resolve` (#27) already consults the -//! guard in the right place (after the intent override, before the provider -//! setting) and #28 only has to replace the body below. +//! If the game crashes (or is killed) while starting on Vulkan, the next +//! launch starts on GLES, so a bad Vulkan driver cannot brick the game. +//! +//! Two marker files, each containing ` `, in the app's +//! `Context.getNoBackupFilesDir()` (`/no_backup`), which Android Auto +//! Backup and device-to-device transfer never copy: a crash on one device's +//! GPU must not disable Vulkan on another. If that JNI lookup fails, the +//! glue falls back to `ANativeActivity.internalDataPath` (logged). The +//! directory is resolved ONCE per process (`markerDirs`) and every write +//! goes there; the launch check ALSO reads the other location +//! (`internalDataPath`, or the derived `/no_backup` after a fallback), +//! and a marker found in either is effective (`checkDirs`). +//! +//! * `.labelle_vulkan_start`: written by `beginVulkanStart`, just before a +//! Vulkan start (the resolved renderer is `vulkan`). +//! * `.labelle_vulkan_disabled`: written when a launch finds a +//! `.labelle_vulkan_start` left by a process that is gone, i.e. a Vulkan +//! start that never reached "stable". +//! +//! Both are written atomically (`.tmp` + `rename`), so a failed or +//! partial write never leaves a malformed marker behind. +//! +//! Rules: +//! * Stable: a detached thread polls (every `poll_interval_ns`, 500 ms) +//! and deletes `.labelle_vulkan_start` once BOTH hold: at least +//! `stable_min_frames` (120) frames presented SINCE THIS START, and at +//! least `stable_after_ns` (10 s) since `beginVulkanStart`. The frame +//! count is labelle-bgfx's generic `labelle_bgfx_frames_presented()` +//! export (labelle-toolkit/labelle-bgfx#182; reset to 0 on every +//! successful `bgfx.init`, +1 per `bgfx.frame()`), looked up at RUNTIME +//! with `dlsym(RTLD_DEFAULT, ...)`: no build dependency on labelle-bgfx. +//! `beginStart` records the counter as a per-start baseline, so frames a +//! previous Activity of the same process presented never count; a value +//! below the baseline means bgfx re-initialised, and counting restarts +//! from 0. A Vulkan init that hangs never presents a frame, so the mark +//! is never cleared and the next launch uses GLES. +//! If the symbol is missing (another backend, or an older labelle-bgfx) +//! the rule falls back to time only (10 s), logged once per process. +//! * Same process: a start mark THIS process wrote and still owns +//! (`StartState.owned`) is not a crash: an Activity relaunched in the +//! same process supersedes it (new generation, new mark) instead. +//! EVERY renderer resolution (`beginResolution`, whatever it picks) +//! bumps the generation, so an earlier Vulkan start's thread stops and +//! its mark stays "incomplete": only a Vulkan start that itself reaches +//! the threshold clears it (a GLES relaunch's frames never do). +//! * Stable unlink failure: ownership is kept and the unlink retried each +//! poll while the process lives (logged once). +//! * Marker READ ERRORS (not "absent") fail closed: guarded, and nothing +//! is written or deleted. +//! * Disabled: while `.labelle_vulkan_disabled` exists and its stamp +//! matches the current `versionCode` and setting, `vulkanDisabled` +//! returns true and `renderer.decide` picks `gles` (source +//! `crash-guard`). +//! * Reset: each marker whose stamp does NOT match (a successfully read +//! new app version or changed `renderer` setting) is deleted on its own, +//! so Vulkan is tried again; a marker that still matches is kept and +//! applied. A value that could not be READ never counts as a change. +//! * Intent override: `renderer.decide` asks the debuggable-only intent +//! extra BEFORE the guard, so it wins (see the test at the bottom). The +//! guard itself stays ACTIVE in debuggable builds: only an explicit +//! extra bypasses it. +//! * Fails CLOSED: if the disabled mark cannot be written, or the stable +//! timer cannot be started, the start mark is KEPT, so the next launch +//! is still guarded (it finds the mark again and goes to GLES). A +//! malformed marker is treated as a match for the current stamp. If the +//! start mark cannot be written at all, `renderer.resolve` runs this +//! launch on GLES (unless the intent override asked for Vulkan). +//! +//! By design, anything that ends the process before the start is stable +//! counts as an incomplete start, including a developer's early force-stop +//! (e.g. `am start -S` within 10 s of the previous launch): the next launch runs +//! on GLES. To bypass the guard while iterating, launch a debuggable build +//! with `--es LABELLE_BGFX_RENDERER vulkan` (the intent override). +//! +//! The PURE half (`checkObserved`, `beginStart`, `settle`) takes an injected +//! filesystem, clock and frame source and is host-tested; the Android glue +//! below it uses libc and the JNI helpers in `jni/renderer_query.c`. const std = @import("std"); +const is_android = @import("root.zig").is_android; + +pub const start_file = ".labelle_vulkan_start"; +pub const disabled_file = ".labelle_vulkan_disabled"; +/// How long the process must live after `beginVulkanStart` before the start +/// counts as stable. +pub const stable_after_ns: u64 = 10 * std.time.ns_per_s; +/// How many frames must have been presented (labelle-bgfx#182's counter) +/// before the start counts as stable. +pub const stable_min_frames: u64 = 120; +/// How often the stable thread re-checks. +pub const poll_interval_ns: u64 = 500 * std.time.ns_per_ms; + +/// What both marker files contain: ` `. +pub const Stamp = struct { + version_code: i64, + /// The effective provider setting (`gles`, `vulkan` or `auto`). + setting: []const u8, + + pub fn eql(a: Stamp, b: Stamp) bool { + return a.version_code == b.version_code and std.mem.eql(u8, a.setting, b.setting); + } + + /// ` `, or null if it does not fit `buf`. + pub fn format(self: Stamp, buf: []u8) ?[]const u8 { + return std.fmt.bufPrint(buf, "{d} {s}", .{ self.version_code, self.setting }) catch null; + } + + /// Parse ` ` (trailing whitespace allowed). Null + /// for anything else; `setting` borrows from `bytes`. + pub fn parse(bytes: []const u8) ?Stamp { + const trimmed = std.mem.trimEnd(u8, bytes, " \t\r\n"); + const sp = std.mem.indexOfScalar(u8, trimmed, ' ') orelse return null; + const version_code = std.fmt.parseInt(i64, trimmed[0..sp], 10) catch return null; + const setting = trimmed[sp + 1 ..]; + if (setting.len == 0 or std.mem.indexOfAny(u8, setting, " \t\r\n") != null) return null; + return .{ .version_code = version_code, .setting = setting }; + } +}; + +/// Room for a stamp: a 20-digit i64, a space and the longest setting. +const stamp_cap = 64; + +/// What this launch could OBSERVE of the current stamp. A null field is a +/// READ FAILURE (JNI error), not a value: it can never count as a change, +/// so it never resets the guard (Codex review of labelle-android#33, +/// labelle-bgfx#172 comment 5904915798). An ABSENT `labelle.renderer` key is +/// a successful read and arrives here as the default `gles`. +pub const Observed = struct { + version_code: ?i64, + setting: ?[]const u8, + + pub fn of(stamp: Stamp) Observed { + return .{ .version_code = stamp.version_code, .setting = stamp.setting }; + } + /// The full stamp, when both halves were read. + pub fn full(self: Observed) ?Stamp { + return .{ .version_code = self.version_code orelse return null, .setting = self.setting orelse return null }; + } +}; + +const Match = enum { same, changed, unknown }; + +/// `changed` only on a SUCCESSFULLY read, different value; a failed read of +/// either half otherwise gives `unknown`. +fn compare(recorded: Stamp, now: Observed) Match { + if (now.version_code) |v| if (v != recorded.version_code) return .changed; + if (now.setting) |x| if (!std.mem.eql(u8, x, recorded.setting)) return .changed; + return if (now.full() == null) .unknown else .same; +} + +/// A marker read: its bytes, a known-absent file, or a READ ERROR (the file +/// may exist but could not be read). Read errors fail closed. +pub const ReadResult = union(enum) { + present: []const u8, + absent, + read_error, +}; + +/// The launch-time check of a NEW process (fresh `StartState`) with a fully +/// read stamp, in a single marker directory (see `checkDirs`). +pub fn checkAtLaunch(fs: anytype, current: Stamp) bool { + var fresh: StartState = .{}; + return checkObserved(fs, Observed.of(current), &fresh); +} + +/// `checkDirs` with only the primary marker directory. +pub fn checkObserved(fs: anytype, current: Observed, state: *StartState) bool { + return checkDirs(fs, @as(?@TypeOf(fs), null), current, state); +} + +/// The launch-time check. `primary` is the marker directory this process +/// writes to; `secondary` (optional) is the OTHER possible directory (a +/// marker from an older build or a fallback launch may be there): it is +/// read, and its markers are just as effective, but never written. Each fs +/// provides: +/// * `read(name: []const u8, buf: []u8) ReadResult` +/// * `write(name: []const u8, bytes: []const u8) bool` +/// * `rename(from: []const u8, to: []const u8) bool` (replaces `to`) +/// * `delete(name: []const u8) bool` (true = gone, incl. already absent) +/// +/// Returns true when this launch must not use Vulkan. +/// * Any marker READ ERROR in either directory: guarded, and NOTHING is +/// written or deleted (fail closed; the next launch re-reads). +/// * Otherwise each marker in each directory is judged on its own stamp +/// (`compare`): +/// - `.labelle_vulkan_disabled`: same → guarded; changed → deleted +/// (only it); unknown (a value could not be read) → kept, guarded; +/// malformed → guarded and rewritten in `primary` for the current +/// stamp (fail closed). +/// - `.labelle_vulkan_start`, unless it is `primary`'s and this process +/// owns it (`state.owned`: a same-process relaunch, not a crash): +/// same (or malformed) → a crashed start: write the disabled mark in +/// `primary`, then drop this start mark, guarded; if that write +/// fails the start mark is KEPT, so the next launch is still +/// guarded; changed → deleted (only it); unknown → kept, guarded. +/// Runs under `state.lock`, like `beginStart` and `settle`'s unlink. +pub fn checkDirs(primary: anytype, secondary: ?@TypeOf(primary), current: Observed, state: *StartState) bool { + state.acquire(); + defer state.release(); + + const Dir = struct { fs: @TypeOf(primary), is_primary: bool }; + var dirs_buf: [2]Dir = undefined; + dirs_buf[0] = .{ .fs = primary, .is_primary = true }; + var n: usize = 1; + if (secondary) |sec| { + dirs_buf[1] = .{ .fs = sec, .is_primary = false }; + n = 2; + } + const dirs = dirs_buf[0..n]; + + // Read everything first: a read error anywhere means "don't touch". + var bufs: [2][2][stamp_cap]u8 = undefined; + var reads: [2][2]ReadResult = undefined; // [dir][0 = disabled, 1 = start] + for (dirs, 0..) |d, i| { + reads[i][0] = d.fs.read(disabled_file, &bufs[i][0]); + reads[i][1] = d.fs.read(start_file, &bufs[i][1]); + for (reads[i]) |r| if (r == .read_error) { + std.log.warn("android: crash guard: could not read a marker; keeping the guard (nothing changed)", .{}); + return true; + }; + } -/// True when a previous launch crashed during a Vulkan start and this launch -/// must use GLES instead. `activity` is the running `ANativeActivity*` -/// (opaque), for the internal-storage path #28 will need. + var guarded = false; + for (dirs, 0..) |d, i| { + switch (reads[i][0]) { + .absent, .read_error => {}, + .present => |bytes| if (Stamp.parse(bytes)) |s| switch (compare(s, current)) { + .same, .unknown => guarded = true, + .changed => _ = d.fs.delete(disabled_file), + } else { + // Malformed: never a reset (that could lose a kept start + // mark). Fail closed and repair it for the current stamp. + std.log.warn("android: crash guard: malformed {s}; keeping the guard", .{disabled_file}); + guarded = true; + if (current.full()) |cur| { + if (writeStamp(primary, disabled_file, cur) and !d.is_primary) _ = d.fs.delete(disabled_file); + } + }, + } + if (d.is_primary and state.owned) continue; + switch (reads[i][1]) { + .absent, .read_error => {}, + .present => |bytes| { + const m: Match = if (Stamp.parse(bytes)) |s| compare(s, current) else .same; + switch (m) { + .same => { + // A start that never became stable, left by a process + // that is gone (not this one: not owned). + guarded = true; + if (current.full()) |cur| { + if (writeStamp(primary, disabled_file, cur)) { + _ = d.fs.delete(start_file); + } else { + // Fail closed: keep the start mark so the + // NEXT launch is still guarded. + std.log.warn("android: crash guard: could not write {s}; keeping {s}", .{ disabled_file, start_file }); + } + } + }, + .unknown => guarded = true, + .changed => _ = d.fs.delete(start_file), + } + }, + } + } + return guarded; +} + +/// Write `.tmp`, then rename it over `name`: a failed or partial write +/// never leaves a malformed `name` (the tmp is removed on failure). +fn writeAtomic(fs: anytype, name: []const u8, bytes: []const u8) bool { + var tmp_buf: [64]u8 = undefined; + const tmp = std.fmt.bufPrint(&tmp_buf, "{s}.tmp", .{name}) catch return false; + if (!fs.write(tmp, bytes) or !fs.rename(tmp, name)) { + _ = fs.delete(tmp); + return false; + } + return true; +} + +fn writeStamp(fs: anytype, name: []const u8, stamp: Stamp) bool { + var out: [stamp_cap]u8 = undefined; + const text = stamp.format(&out) orelse return false; + return writeAtomic(fs, name, text); +} + +/// Write `.labelle_vulkan_start` (atomically; called just before a Vulkan +/// start). +pub fn markStart(fs: anytype, current: Stamp) bool { + return writeStamp(fs, start_file, current); +} + +/// The start mark's ownership, shared by `checkObserved`, `beginStart` and +/// every stable thread of the process. `lock` serialises the steps that +/// must not interleave: +/// * `beginStart`: { generation += 1; write the start mark; owned = true } +/// * `settle`'s final step: { generation still == mine?; unlink the mark; +/// owned = false } +/// * `checkObserved`: { read `owned`; judge the markers } +/// so an old stable thread can never unlink a mark a newer launch just +/// wrote (Codex review of labelle-android#33, labelle-bgfx#172 comment +/// 5904658178), and a same-process relaunch never mistakes this process's +/// own live mark for a crash. The frame counter and the clock are read +/// OUTSIDE the lock. +/// +/// `std.atomic.Mutex` spun on `tryLock` (Zig 0.16 has no `std.Thread.Mutex`, +/// and `std.Io.Mutex` needs an `Io`): the critical sections are a few +/// syscalls on tiny files and contention needs two activity launches in +/// the same instant, so a spin is fine. +pub const StartState = struct { + lock: std.atomic.Mutex = .unlocked, + /// Written only under `lock`; read without it (atomically) only for the + /// stable loop's early exit, which is advisory. + generation: u32 = 0, + /// Under `lock`: the start mark on disk was written by THIS process and + /// its stable thread has not cleared it yet. + owned: bool = false, + + fn acquire(self: *StartState) void { + while (!self.lock.tryLock()) std.atomic.spinLoopHint(); + } + fn release(self: *StartState) void { + self.lock.unlock(); + } + fn peek(self: *const StartState) u32 { + return @atomicLoad(u32, &self.generation, .acquire); + } +}; + +/// `beginVulkanStart`'s pure half. Reads the frame counter as this start's +/// baseline (`src.framesPresented()`, outside the lock); under `state.lock` +/// bumps the generation and writes the start mark; then starts the stable +/// thread via `spawner.spawn(mine: u32, baseline: ?u64) !void`. +/// +/// Returns false when the start mark could NOT be written: the caller then +/// must not start Vulkan unguarded (`renderer.afterStart` → GLES). If only +/// the thread cannot start, the mark is KEPT and this returns true: Vulkan +/// runs, and the next launch is guarded (fail closed). +pub fn beginStart(fs: anytype, current: Stamp, state: *StartState, src: anytype, spawner: anytype) bool { + const baseline = src.framesPresented(); + state.acquire(); + const mine = state.generation +% 1; + @atomicStore(u32, &state.generation, mine, .release); + const written = markStart(fs, current); + if (written) state.owned = true; + state.release(); + if (!written) { + std.log.warn("android: crash guard: could not write {s}", .{start_file}); + return false; + } + spawner.spawn(mine, baseline) catch |err| { + std.log.warn("android: crash guard: could not start the stable timer ({s}); keeping {s}, so the next launch uses gles", .{ @errorName(err), start_file }); + }; + return true; +} + +/// A new renderer resolution in this process (whatever it picks): bump the +/// generation so every earlier stable thread stops, WITHOUT touching the +/// start mark. A Vulkan start that was superseded before it reached the +/// threshold stays "incomplete" (the mark, still `owned`, is only cleared by +/// a Vulkan start that itself becomes stable); otherwise a GLES relaunch's +/// frames (after bgfx re-init resets the counter) could clear the Vulkan +/// mark of a start that never presented 120 frames. +pub fn supersede(state: *StartState) void { + state.acquire(); + defer state.release(); + @atomicStore(u32, &state.generation, state.generation +% 1, .release); +} + +/// Frames presented since this start: the counter minus `baseline.*`. A +/// counter BELOW the baseline means bgfx re-initialised (labelle-bgfx#182 +/// resets it on every successful init): the baseline drops to 0 for good. +fn framesSince(raw: u64, baseline: *?u64) u64 { + const b = baseline.* orelse return raw; + if (raw < b) { + baseline.* = 0; + return raw; + } + return raw - b; +} + +/// The stable thread. Polls every `poll_interval_ns` and deletes the start +/// mark once `stable_after_ns` has passed since it started AND at least +/// `stable_min_frames` frames were presented since `baseline` (the counter +/// at `beginStart`; see `framesSince`). A newer `beginStart` (another +/// activity launch in the same process) takes the mark over: this thread +/// then stops without deleting. The final check-and-unlink runs under +/// `state.lock`, so it cannot interleave with a newer `beginStart`. /// -/// Stub: always "not disabled". -pub fn isVulkanDisabled(activity: ?*const anyopaque) bool { - _ = activity; - return false; +/// Injected: +/// * `clock.now() u64` (monotonic ns) and `clock.sleep(ns) !void`; a fake +/// returns an error to model the process dying mid-sleep. +/// * `src.framesPresented() ?u64`: null = the labelle-bgfx symbol is +/// missing → time-only rule; then `src.noteTimeOnly()` is called once +/// (the glue logs once per process). +pub fn settle(fs: anytype, clock: anytype, src: anytype, state: *StartState, mine: u32, baseline: ?u64) void { + const t0 = clock.now(); + var noted = false; + var unlink_failure_logged = false; + var base = baseline; + // Advisory early exit; the authoritative check is under the lock below. + while (state.peek() == mine) { + const raw = src.framesPresented(); + if (raw == null and !noted) { + noted = true; + src.noteTimeOnly(); + } + const elapsed = clock.now() -% t0; + const enough_frames = if (raw) |r| framesSince(r, &base) >= stable_min_frames else true; + if (elapsed >= stable_after_ns and enough_frames) { + state.acquire(); + const done = if (state.generation != mine) true else if (fs.delete(start_file)) blk: { + state.owned = false; + break :blk true; + } else false; + state.release(); + if (done) return; + // The unlink failed: the mark is still ours (owned stays true), + // so retry on the next poll for as long as the process lives. + if (!unlink_failure_logged) { + unlink_failure_logged = true; + std.log.warn("android: crash guard: could not delete {s}; retrying", .{start_file}); + } + } + clock.sleep(poll_interval_ns) catch return; + } +} + +// ── Android glue ──────────────────────────────────────────────────────── + +// `jni/renderer_query.c`. +extern "c" fn labelle_android_internal_data_path(activity: ?*const anyopaque) ?[*:0]const u8; +extern "c" fn labelle_android_no_backup_dir(activity: ?*const anyopaque, buf: [*]u8, buf_cap: usize) c_int; +extern "c" fn labelle_android_version_code(activity: ?*const anyopaque, out: *c_longlong) c_int; +extern "c" fn labelle_android_frames_presented(out: *u64) c_int; +extern "c" fn labelle_android_monotonic_ns() u64; +// libc. +const FILE = opaque {}; +extern "c" fn fopen(name: [*:0]const u8, mode: [*:0]const u8) ?*FILE; +extern "c" fn fread(ptr: [*]u8, size: usize, n: usize, f: *FILE) usize; +extern "c" fn fwrite(ptr: [*]const u8, size: usize, n: usize, f: *FILE) usize; +extern "c" fn fclose(f: *FILE) c_int; +extern "c" fn unlink(name: [*:0]const u8) c_int; +extern "c" fn rename(from: [*:0]const u8, to: [*:0]const u8) c_int; +extern "c" fn ferror(f: *FILE) c_int; +/// bionic's `errno` location. +extern "c" fn __errno() *c_int; +const enoent: c_int = 2; +extern "c" fn usleep(usec: c_uint) c_int; + +/// Marker files under a copied directory path (a value type, so the +/// detached stable thread owns its own copy). +const LibcFs = struct { + dir_buf: [512]u8 = undefined, + dir_len: usize = 0, + + fn init(dir: []const u8) ?LibcFs { + var self: LibcFs = .{}; + if (dir.len == 0 or dir.len > self.dir_buf.len) return null; + @memcpy(self.dir_buf[0..dir.len], dir); + self.dir_len = dir.len; + return self; + } + + fn full(self: *const LibcFs, name: []const u8, buf: []u8) ?[:0]const u8 { + return std.fmt.bufPrintZ(buf, "{s}/{s}", .{ self.dir_buf[0..self.dir_len], name }) catch null; + } + + pub fn read(self: *const LibcFs, name: []const u8, buf: []u8) ReadResult { + var p: [600]u8 = undefined; + const path_z = self.full(name, &p) orelse return .read_error; + const f = fopen(path_z.ptr, "rb") orelse + return if (__errno().* == enoent) .absent else .read_error; + defer _ = fclose(f); + const n = fread(buf.ptr, 1, buf.len, f); + if (ferror(f) != 0) return .read_error; + return .{ .present = buf[0..n] }; + } + pub fn write(self: *const LibcFs, name: []const u8, bytes: []const u8) bool { + var p: [600]u8 = undefined; + const f = fopen((self.full(name, &p) orelse return false).ptr, "wb") orelse return false; + const ok = fwrite(bytes.ptr, 1, bytes.len, f) == bytes.len; + return fclose(f) == 0 and ok; + } + pub fn rename(self: *const LibcFs, from: []const u8, to: []const u8) bool { + var pf: [600]u8 = undefined; + var pt: [600]u8 = undefined; + const f = self.full(from, &pf) orelse return false; + const t = self.full(to, &pt) orelse return false; + return rename_c(f.ptr, t.ptr) == 0; + } + pub fn delete(self: *const LibcFs, name: []const u8) bool { + var p: [600]u8 = undefined; + const path_z = self.full(name, &p) orelse return false; + return unlink(path_z.ptr) == 0 or __errno().* == enoent; + } +}; +const rename_c = rename; + +const LibcClock = struct { + pub fn now(_: LibcClock) u64 { + return labelle_android_monotonic_ns(); + } + pub fn sleep(_: LibcClock, ns: u64) error{}!void { + // A signal may cut it short; the poll loop re-checks either way. + _ = usleep(@intCast(@min(ns / std.time.ns_per_us, 1_000_000))); + } +}; + +/// Set once the time-only fallback has been logged (once per process). +var time_only_logged: std.atomic.Value(bool) = .init(false); + +/// labelle-bgfx#182's frame counter via `dlsym` (`jni/renderer_query.c`). +const DlsymFrames = struct { + pub fn framesPresented(_: DlsymFrames) ?u64 { + var n: u64 = 0; + return if (labelle_android_frames_presented(&n) != 0) n else null; + } + pub fn noteTimeOnly(_: DlsymFrames) void { + if (logOnce(&time_only_logged)) { + std.log.warn("crash guard: labelle_bgfx_frames_presented not found; using the time-only stable rule", .{}); + } + } +}; + +/// True for exactly one caller over the flag's lifetime. +fn logOnce(flag: *std.atomic.Value(bool)) bool { + return !flag.swap(true, .acq_rel); +} + +/// The process's start-mark ownership (see `StartState`). +var start_state: StartState = .{}; + +/// Process-wide `versionCode` cache: it cannot change for the life of the +/// process (an update kills it). -1 = not read yet. Only a SUCCESSFUL read +/// is cached, so a transient JNI failure is retried on the next call. +var version_cache: std.atomic.Value(i64) = .init(-1); +var version_failure_logged: std.atomic.Value(bool) = .init(false); + +/// The app's versionCode, or null when the JNI read failed (never a +/// synthetic value: a fake 0 would look like a version change and reset the +/// guard). +fn versionCode(activity: *const anyopaque) ?i64 { + const cached = version_cache.load(.acquire); + if (cached >= 0) return cached; + var v: c_longlong = 0; + if (labelle_android_version_code(activity, &v) != 0 or v < 0) { + if (logOnce(&version_failure_logged)) { + std.log.warn("android: crash guard: could not read the app's versionCode; keeping the guard's marks as they are", .{}); + } + return null; + } + version_cache.store(v, .release); + return v; +} + +/// `/../no_backup`: where `getNoBackupFilesDir()` lives next to +/// `internalDataPath` (`/files`). Only used to READ markers a process +/// that did get the JNI answer may have left there. +fn siblingNoBackup(internal: []const u8, buf: []u8) ?[]const u8 { + const trimmed = std.mem.trimEnd(u8, internal, "/"); + const slash = std.mem.lastIndexOfScalar(u8, trimmed, '/') orelse return null; + if (slash == 0) return null; + return std.fmt.bufPrint(buf, "{s}/no_backup", .{trimmed[0..slash]}) catch null; +} + +/// The marker directories, resolved ONCE per process: `primary` is where +/// markers are written, `secondary` the other location, read at launch. +const MarkerDirs = struct { primary: LibcFs, secondary: ?LibcFs }; + +var marker_dirs: ?MarkerDirs = null; +var marker_dirs_lock: std.atomic.Mutex = .unlocked; +var marker_dirs_resolved: bool = false; + +/// `getNoBackupFilesDir()` (never backed up or transferred) is primary and +/// `internalDataPath` secondary; if the JNI lookup fails, `internalDataPath` +/// is primary (logged) and the derived `/no_backup` secondary. +fn markerDirs(activity: *const anyopaque) ?MarkerDirs { + while (!marker_dirs_lock.tryLock()) std.atomic.spinLoopHint(); + defer marker_dirs_lock.unlock(); + if (marker_dirs_resolved) return marker_dirs; + marker_dirs_resolved = true; + + const internal: ?LibcFs = if (labelle_android_internal_data_path(activity)) |d| LibcFs.init(std.mem.span(d)) else null; + var nb: [512]u8 = undefined; + const n = labelle_android_no_backup_dir(activity, &nb, nb.len); + if (n > 0) if (LibcFs.init(nb[0..@intCast(n)])) |no_backup| { + marker_dirs = .{ .primary = no_backup, .secondary = internal }; + return marker_dirs; + }; + std.log.warn("android: crash guard: could not get noBackupFilesDir; using internalDataPath for this process (Auto Backup may copy the crash-guard markers)", .{}); + const primary = internal orelse { + std.log.warn("android: crash guard: no usable internalDataPath; guard off", .{}); + return null; + }; + var sib: [512]u8 = undefined; + const secondary: ?LibcFs = if (siblingNoBackup(primary.dir_buf[0..primary.dir_len], &sib)) |d| LibcFs.init(d) else null; + marker_dirs = .{ .primary = primary, .secondary = secondary }; + return marker_dirs; +} + +/// True when a previous launch crashed during a Vulkan start (and the app +/// version and `setting` are unchanged since), so this launch must use GLES. +/// Also applies the reset rule. `activity` is the running `ANativeActivity*` +/// (opaque); `setting` the effective provider setting (`gles`|`vulkan`| +/// `auto`), or null when the meta-data could not be READ (then, like a +/// failed versionCode read, the marks are preserved: see `checkObserved`). +/// Off Android, or with no activity or data dir: false. +pub fn vulkanDisabled(activity: ?*const anyopaque, setting: ?[]const u8) bool { + if (comptime !is_android) return false; + const a = activity orelse return false; + var dirs = markerDirs(a) orelse return false; + const sec: ?*LibcFs = if (dirs.secondary) |*s2| s2 else null; + return checkDirs(&dirs.primary, sec, .{ .version_code = versionCode(a), .setting = setting }, &start_state); +} + +/// Called by `renderer.resolve` at the start of EVERY renderer resolution: +/// stops earlier stable threads of this process without clearing their +/// start mark (see `supersede`). +pub fn beginResolution() void { + if (comptime !is_android) return; + supersede(&start_state); +} + +/// Called by `renderer.resolve` once Vulkan is chosen, before bgfx starts: +/// write `.labelle_vulkan_start` and start the detached stable thread +/// (120 frames and 10 s, or 10 s alone without labelle-bgfx#182). +/// `setting` null = the meta-data could not be read. A start mark needs the +/// full stamp, so with either half unreadable no mark is written (logged). +/// Returns whether the start mark was recorded; when it was not, +/// `renderer.afterStart` keeps this launch off Vulkan (fail closed). +pub fn beginVulkanStart(activity: ?*const anyopaque, setting: ?[]const u8) bool { + if (comptime !is_android) return true; + const a = activity orelse return false; + const fs = (markerDirs(a) orelse return false).primary; + const obs: Observed = .{ .version_code = versionCode(a), .setting = setting }; + const stamp = obs.full() orelse { + std.log.warn("android: crash guard: versionCode or {s} unreadable; cannot record this Vulkan start", .{"labelle.renderer"}); + return false; + }; + return beginStart(&fs, stamp, &start_state, DlsymFrames{}, ThreadSpawner{ .fs = fs }); +} + +/// Spawns the detached stable-timer thread with its own copy of the fs. +const ThreadSpawner = struct { + fs: LibcFs, + pub fn spawn(self: ThreadSpawner, mine: u32, baseline: ?u64) std.Thread.SpawnError!void { + const t = try std.Thread.spawn(.{}, stableThread, .{ self.fs, mine, baseline }); + t.detach(); + } +}; + +fn stableThread(fs: LibcFs, mine: u32, baseline: ?u64) void { + settle(&fs, LibcClock{}, DlsymFrames{}, &start_state, mine, baseline); +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +const testing = std.testing; + +/// In-memory marker files (and their `.tmp` siblings), persisting across +/// "launches" like storage does. +const FakeFs = struct { + start: ?[]const u8 = null, + disabled: ?[]const u8 = null, + start_tmp: ?[]const u8 = null, + disabled_tmp: ?[]const u8 = null, + bufs: [4][stamp_cap]u8 = undefined, + /// Every write fails without touching anything. + fail_writes: bool = false, + /// Every write stores HALF the bytes, then reports failure (ENOSPC). + partial_writes: bool = false, + /// Reads of this file name report a read error (EIO, EMFILE, ...). + read_error_on: ?[]const u8 = null, + /// The next N deletes of an EXISTING file fail (the file stays). + failing_deletes: usize = 0, + + fn slot(self: *FakeFs, name: []const u8) struct { *?[]const u8, []u8 } { + if (std.mem.eql(u8, name, start_file)) return .{ &self.start, &self.bufs[0] }; + if (std.mem.eql(u8, name, disabled_file)) return .{ &self.disabled, &self.bufs[1] }; + if (std.mem.eql(u8, name, start_file ++ ".tmp")) return .{ &self.start_tmp, &self.bufs[2] }; + if (std.mem.eql(u8, name, disabled_file ++ ".tmp")) return .{ &self.disabled_tmp, &self.bufs[3] }; + @panic("unexpected file name"); + } + pub fn read(self: *FakeFs, name: []const u8, buf: []u8) ReadResult { + if (self.read_error_on) |bad| if (std.mem.eql(u8, bad, name)) return .read_error; + const s = self.slot(name); + const v = s[0].* orelse return .absent; + @memcpy(buf[0..v.len], v); + return .{ .present = buf[0..v.len] }; + } + pub fn write(self: *FakeFs, name: []const u8, bytes: []const u8) bool { + if (self.fail_writes) return false; + const s = self.slot(name); + const n = if (self.partial_writes) bytes.len / 2 else bytes.len; + @memcpy(s[1][0..n], bytes[0..n]); + s[0].* = s[1][0..n]; + return !self.partial_writes; + } + pub fn rename(self: *FakeFs, from: []const u8, to: []const u8) bool { + const f = self.slot(from); + const v = f[0].* orelse return false; + const t = self.slot(to); + @memcpy(t[1][0..v.len], v); + t[0].* = t[1][0..v.len]; + f[0].* = null; + return true; + } + pub fn delete(self: *FakeFs, name: []const u8) bool { + const s = self.slot(name); + if (s[0].* != null and self.failing_deletes > 0) { + self.failing_deletes -= 1; + return false; + } + s[0].* = null; + return true; + } + fn set(self: *FakeFs, name: []const u8, bytes: []const u8) void { + const s = self.slot(name); + @memcpy(s[1][0..bytes.len], bytes); + s[0].* = s[1][0..bytes.len]; + } +}; + +/// `sleep` advances a fake clock; the process "dies" at `dies_at_ns`. +const FakeClock = struct { + now_ns: u64 = 0, + dies_at_ns: ?u64 = null, + slept_ns: u64 = 0, + + pub fn now(self: *FakeClock) u64 { + return self.now_ns; + } + pub fn sleep(self: *FakeClock, ns: u64) error{ProcessDied}!void { + if (self.dies_at_ns) |d| if (self.now_ns + ns >= d) { + self.now_ns = d; + return error.ProcessDied; + }; + self.now_ns += ns; + self.slept_ns += ns; + } +}; + +/// labelle-bgfx#182's counter as a function of the fake clock: +/// `before_init` until `init_done_ns` (null = init never finishes, i.e. +/// hangs; `before_init` > 0 = a previous Activity's frames), then from 0 at +/// `fps` frames per second (the reset on a successful init). `missing` = the +/// symbol is not exported. +const FakeFrames = struct { + clock: *FakeClock, + fps: u64 = 60, + init_done_ns: ?u64 = 0, + before_init: u64 = 0, + missing: bool = false, + notes: usize = 0, + calls: usize = 0, + /// Run a real `beginStart` (a newer launch in the same process: new + /// generation + new mark) inside the Nth `framesPresented` call, i.e. + /// after `settle`'s loop-entry check and before its stable decision. + restart: ?struct { at_call: usize, fs: *FakeFs, state: *StartState, stamp: Stamp } = null, + + pub fn framesPresented(self: *FakeFrames) ?u64 { + self.calls += 1; + if (self.restart) |r| if (self.calls == r.at_call) { + _ = beginStart(r.fs, r.stamp, r.state, NoFrames{}, NoSpawner{}); + }; + if (self.missing) return null; + const done = self.init_done_ns orelse return self.before_init; + if (self.clock.now_ns < done) return self.before_init; + return (self.clock.now_ns - done) * self.fps / std.time.ns_per_s; + } + pub fn noteTimeOnly(self: *FakeFrames) void { + self.notes += 1; + } +}; + +const NoSpawner = struct { + pub fn spawn(_: NoSpawner, _: u32, _: ?u64) error{}!void {} +}; + +/// A frame source with no counter (for `beginStart`'s baseline read). +const NoFrames = struct { + pub fn framesPresented(_: NoFrames) ?u64 { + return null; + } + pub fn noteTimeOnly(_: NoFrames) void {} +}; + +/// `checkObserved` as a NEW process (fresh in-process state). +fn checkNew(fs: anytype, obs: Observed) bool { + var fresh: StartState = .{}; + return checkObserved(fs, obs, &fresh); +} + +const v1_vulkan: Stamp = .{ .version_code = 1, .setting = "vulkan" }; +const v2_vulkan: Stamp = .{ .version_code = 2, .setting = "vulkan" }; + +/// One Vulkan launch: check the guard; if clear, mark the start and run the +/// stable thread on `clock` with a healthy 60 fps renderer. +fn launchVulkan(fs: *FakeFs, clock: *FakeClock, state: *StartState, stamp: Stamp) bool { + if (checkAtLaunch(fs, stamp)) return false; + var got: ?u32 = null; + _ = beginStart(fs, stamp, state, NoFrames{}, RecordingSpawner{ .got = &got }); + var frames: FakeFrames = .{ .clock = clock }; + if (got) |mine| settle(fs, clock, &frames, state, mine, null); + return true; +} + +const RecordingSpawner = struct { + got: *?u32, + baseline: ?*?u64 = null, + pub fn spawn(self: RecordingSpawner, mine: u32, baseline: ?u64) error{}!void { + self.got.* = mine; + if (self.baseline) |b| b.* = baseline; + } +}; + +/// Mark a start and run only the stable thread; returns the frame fake. +fn runSettle(fs: *FakeFs, clock: *FakeClock, frames: *FakeFrames) void { + var state: StartState = .{}; + _ = beginStart(fs, v1_vulkan, &state, NoFrames{}, NoSpawner{}); + settle(fs, clock, frames, &state, state.generation, null); +} + +test "stamp: format and parse round-trip; junk is rejected" { + var buf: [stamp_cap]u8 = undefined; + const text = v1_vulkan.format(&buf).?; + try testing.expectEqualStrings("1 vulkan", text); + try testing.expect(Stamp.parse(text).?.eql(v1_vulkan)); + try testing.expect(Stamp.parse("42 auto\n").?.eql(.{ .version_code = 42, .setting = "auto" })); + try testing.expect(Stamp.parse("9223372036854775807 gles").?.version_code == std.math.maxInt(i64)); + inline for (.{ "", "1", "1 ", "x vulkan", " 1 vulkan", "1 vul kan", "1\tvulkan" }) |bad| { + try testing.expect(Stamp.parse(bad) == null); + } +} + +test "first start: nothing on disk, Vulkan allowed, start mark written" { + var fs: FakeFs = .{}; + try testing.expect(!checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.start == null and fs.disabled == null); + try testing.expect(markStart(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.start.?); +} + +test "a crash inside 10 s disables Vulkan at the next launch" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + var clock: FakeClock = .{ .dies_at_ns = stable_after_ns - 1 }; + try testing.expect(launchVulkan(&fs, &clock, &state, v1_vulkan)); + // The thread never saw 10 s, so the mark survived the "crash". + try testing.expect(clock.slept_ns < stable_after_ns); + try testing.expectEqualStrings("1 vulkan", fs.start.?); + + // Next launch: the stale start becomes the disabled mark. + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.start == null); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); + // ...and it stays disabled on every later launch with the same stamp. + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); +} + +test "a failed disabled write keeps the start mark (fail closed): still guarded next launch" { + var fs: FakeFs = .{}; + fs.set(start_file, "1 vulkan"); + fs.fail_writes = true; + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.disabled == null); + try testing.expectEqualStrings("1 vulkan", fs.start.?); // kept + // Next launch: still guarded by the kept start mark... + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.start.?); + // ...and once writes work again, it converts to the disabled mark. + fs.fail_writes = false; + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.start == null); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); +} + +test "a stable timer that cannot start keeps the start mark (fail closed)" { + const FailingSpawner = struct { + calls: *usize, + pub fn spawn(self: @This(), _: u32, _: ?u64) error{SystemResources}!void { + self.calls.* += 1; + return error.SystemResources; + } + }; + var fs: FakeFs = .{}; + var state: StartState = .{}; + var calls: usize = 0; + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, FailingSpawner{ .calls = &calls })); // mark written + try testing.expectEqual(@as(usize, 1), calls); + try testing.expectEqualStrings("1 vulkan", fs.start.?); // kept + // The next launch treats it as an incomplete start: gles. + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); +} + +test "beginStart: mark written, timer spawned with the new generation" { + var fs: FakeFs = .{}; + var state: StartState = .{ .generation = 4 }; + var got: ?u32 = null; + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, RecordingSpawner{ .got = &got })); + try testing.expectEqual(@as(?u32, 5), got); + try testing.expectEqualStrings("1 vulkan", fs.start.?); +} + +test "surviving 10 s (with frames) clears the mark; the next launch tries Vulkan again" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + var clock: FakeClock = .{}; + try testing.expect(launchVulkan(&fs, &clock, &state, v1_vulkan)); + try testing.expectEqual(stable_after_ns, clock.now_ns); + try testing.expect(fs.start == null and fs.disabled == null); + try testing.expect(!checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.disabled == null); +} + +test "a superseded stable timer leaves the newer start mark alone" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + _ = beginStart(&fs, v1_vulkan, &state, NoFrames{}, NoSpawner{}); + const first = state.generation; + var clock: FakeClock = .{}; + // A second beginStart lands on the 3rd poll, well before 10 s. + var frames: FakeFrames = .{ .clock = &clock, .restart = .{ .at_call = 3, .fs = &fs, .state = &state, .stamp = v2_vulkan } }; + settle(&fs, &clock, &frames, &state, first, null); + try testing.expectEqual(@as(usize, 3), frames.calls); // it polled... + try testing.expect(clock.now_ns < stable_after_ns); // ...stopped early... + try testing.expectEqualStrings("2 vulkan", fs.start.?); // ...and left the new mark +} + +test "race: a newer start landing at the very poll that would clear keeps its mark" { + // Codex review (labelle-bgfx#172 comment 5904658178). Poll 21 is t = 10 s + // (500 ms polls from 0) with 600 frames at 60 fps, so it is the poll + // whose stable decision says "clear". The newer beginStart runs inside + // that poll, AFTER the loop-entry generation check. Without the locked + // re-check the old thread unlinks the new launch's mark (this test then + // fails: fs.start == null). + var fs: FakeFs = .{}; + var state: StartState = .{}; + _ = beginStart(&fs, v1_vulkan, &state, NoFrames{}, NoSpawner{}); + const first = state.generation; + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock, .restart = .{ .at_call = 21, .fs = &fs, .state = &state, .stamp = v2_vulkan } }; + settle(&fs, &clock, &frames, &state, first, null); + try testing.expectEqual(@as(usize, 21), frames.calls); + try testing.expectEqual(stable_after_ns, clock.now_ns); // it reached the stable branch + try testing.expectEqual(first + 1, state.generation); + try testing.expect(fs.start != null); // the new mark survived... + try testing.expectEqualStrings("2 vulkan", fs.start.?); // ...and it IS the new one + try testing.expect(state.lock.tryLock()); // settle released the lock + state.release(); +} + +test "the stable thread's own clear still works under the lock" { + var fs: FakeFs = .{}; + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock }; + var state: StartState = .{}; + _ = beginStart(&fs, v1_vulkan, &state, NoFrames{}, NoSpawner{}); + try testing.expect(state.lock.tryLock()); // beginStart released it... + try testing.expect(!state.lock.tryLock()); // ...and it is exclusive + state.release(); + settle(&fs, &clock, &frames, &state, state.generation, null); + try testing.expect(fs.start == null); + try testing.expect(state.lock.tryLock()); + state.release(); +} + +test "stable: 120 frames but under 10 s -> not cleared" { + var fs: FakeFs = .{}; + var clock: FakeClock = .{ .dies_at_ns = stable_after_ns - 1 }; + var frames: FakeFrames = .{ .clock = &clock, .fps = 1000 }; + runSettle(&fs, &clock, &frames); + try testing.expect(frames.framesPresented().? >= stable_min_frames); // frames were there + try testing.expect(fs.start != null); + try testing.expectEqual(@as(usize, 0), frames.notes); +} + +test "stable: 10 s but under 120 frames (a hung init) -> not cleared" { + var fs: FakeFs = .{}; + // Init never finishes; the player kills it after a minute. + var clock: FakeClock = .{ .dies_at_ns = 60 * std.time.ns_per_s }; + var frames: FakeFrames = .{ .clock = &clock, .init_done_ns = null }; + runSettle(&fs, &clock, &frames); + try testing.expect(clock.now_ns >= stable_after_ns); + try testing.expect(fs.start != null); + + // Init finished but only a few frames (a stall after the first frames). + var fs2: FakeFs = .{}; + var clock2: FakeClock = .{ .dies_at_ns = 60 * std.time.ns_per_s }; + var frames2: FakeFrames = .{ .clock = &clock2, .fps = 1, .init_done_ns = 0 }; + runSettle(&fs2, &clock2, &frames2); + try testing.expect(frames2.framesPresented().? < stable_min_frames); + try testing.expect(fs2.start != null); +} + +test "stable: both 120 frames and 10 s -> cleared, at whichever comes last" { + // Frames first (60 fps: 120 at 2 s), then time: cleared at 10 s. + var fs: FakeFs = .{}; + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock }; + runSettle(&fs, &clock, &frames); + try testing.expect(fs.start == null); + try testing.expectEqual(stable_after_ns, clock.now_ns); + + // Time first (slow init: frames start at 9 s, 20 fps → 120 at 15 s). + var fs2: FakeFs = .{}; + var clock2: FakeClock = .{}; + var frames2: FakeFrames = .{ .clock = &clock2, .fps = 20, .init_done_ns = 9 * std.time.ns_per_s }; + runSettle(&fs2, &clock2, &frames2); + try testing.expect(fs2.start == null); + try testing.expectEqual(15 * std.time.ns_per_s, clock2.now_ns); +} + +test "stable: missing symbol -> time-only rule, noted once" { + var fs: FakeFs = .{}; + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock, .missing = true }; + runSettle(&fs, &clock, &frames); + try testing.expect(fs.start == null); + try testing.expectEqual(stable_after_ns, clock.now_ns); + try testing.expect(frames.calls > 1); // polled many times... + try testing.expectEqual(@as(usize, 1), frames.notes); // ...noted once + + // Before 10 s it is still not cleared. + var fs2: FakeFs = .{}; + var clock2: FakeClock = .{ .dies_at_ns = stable_after_ns - 1 }; + var frames2: FakeFrames = .{ .clock = &clock2, .missing = true }; + runSettle(&fs2, &clock2, &frames2); + try testing.expect(fs2.start != null); +} + +test "logOnce: the time-only log fires for exactly one caller per process" { + var flag: std.atomic.Value(bool) = .init(false); + try testing.expect(logOnce(&flag)); + try testing.expect(!logOnce(&flag)); + try testing.expect(!logOnce(&flag)); +} + +test "a version bump resets both files and Vulkan is tried again" { + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.set(start_file, "1 vulkan"); + const v2: Stamp = .{ .version_code = 2, .setting = "vulkan" }; + try testing.expect(!checkAtLaunch(&fs, v2)); + try testing.expect(fs.start == null and fs.disabled == null); + + // Same for a stale start mark alone (crashed on v1, launched on v2). + fs.set(start_file, "1 vulkan"); + try testing.expect(!checkAtLaunch(&fs, v2)); + try testing.expect(fs.start == null and fs.disabled == null); +} + +test "a changed renderer setting resets both files" { + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.set(start_file, "1 vulkan"); + try testing.expect(!checkAtLaunch(&fs, .{ .version_code = 1, .setting = "auto" })); + try testing.expect(fs.start == null and fs.disabled == null); + + fs.set(start_file, "1 auto"); + try testing.expect(!checkAtLaunch(&fs, .{ .version_code = 1, .setting = "gles" })); + try testing.expect(fs.start == null and fs.disabled == null); +} + +test "a malformed marker is never a reset: fail closed, repaired for the current stamp" { + var fs: FakeFs = .{}; + fs.set(disabled_file, "garbage"); + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); + + var fs2: FakeFs = .{}; + fs2.set(start_file, ""); + try testing.expect(checkAtLaunch(&fs2, v1_vulkan)); // an unfinished start + try testing.expect(fs2.start == null); + try testing.expectEqualStrings("1 vulkan", fs2.disabled.?); +} + +test "a disabled launch drops a leftover start mark" { + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.set(start_file, "1 vulkan"); + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.start == null); + try testing.expect(fs.disabled != null); +} + +test "an intent override beats the guard (renderer.decide asks the intent first)" { + const renderer = @import("renderer.zig"); + const Q = struct { + fs: *FakeFs, + extra: ?[]const u8, + is_debuggable: bool, + guard_calls: usize = 0, + pub fn intentExtra(self: *@This()) ?[]const u8 { + return self.extra; + } + pub fn debuggable(self: *@This()) bool { + return self.is_debuggable; + } + pub fn vulkanDisabled(self: *@This()) bool { + self.guard_calls += 1; + return checkAtLaunch(self.fs, v1_vulkan); + } + pub fn metaData(_: *@This()) ?[]const u8 { + return "vulkan"; + } + pub fn hasVulkan(_: *@This()) bool { + return true; + } + }; + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + + // Debuggable + extra: Vulkan from the intent; the guard is not consulted. + var q: Q = .{ .fs = &fs, .extra = "vulkan", .is_debuggable = true }; + const d = renderer.decide(&q); + try testing.expectEqual(renderer.Renderer.vulkan, d.renderer); + try testing.expectEqual(renderer.Source.intent, d.source); + try testing.expectEqual(@as(usize, 0), q.guard_calls); + + // Debuggable WITHOUT the extra: the guard stays active (option c). + var n: Q = .{ .fs = &fs, .extra = null, .is_debuggable = true }; + const g = renderer.decide(&n); + try testing.expectEqual(renderer.Renderer.gles, g.renderer); + try testing.expectEqual(renderer.Source.crash_guard, g.source); + try testing.expectEqual(@as(usize, 1), n.guard_calls); + + // Not debuggable: the extra is ignored and the guard wins. + var r: Q = .{ .fs = &fs, .extra = "vulkan", .is_debuggable = false }; + const e = renderer.decide(&r); + try testing.expectEqual(renderer.Renderer.gles, e.renderer); + try testing.expectEqual(renderer.Source.crash_guard, e.source); + try testing.expectEqual(@as(usize, 1), r.guard_calls); +} + +test "the Android entry points are no-ops off Android" { + if (comptime is_android) return error.SkipZigTest; + try testing.expect(!vulkanDisabled(null, "vulkan")); + try testing.expect(!vulkanDisabled(@ptrFromInt(0x1000), "vulkan")); + try testing.expect(beginVulkanStart(@ptrFromInt(0x1000), "vulkan")); +} + +test "Android: the glue is analysed (compile-check only; nothing runs)" { + if (comptime !is_android) return error.SkipZigTest; + // Address-of forces semantic analysis of the Android branches, their + // externs and the JNI helpers' signatures in the NDK compile-check. + _ = &vulkanDisabled; + _ = &beginVulkanStart; + _ = &stableThread; + _ = &@import("renderer.zig").resolve; +} + +/// One launch through `renderer.decide` with the guard wired as the glue +/// wires it: the meta-data read's outcome (`renderer.MetaRead`) and a +/// versionCode read that may fail (null). +const LaunchQuery = struct { + const renderer = @import("renderer.zig"); + fs: *FakeFs, + meta: renderer.MetaRead, + version: ?i64, + + pub fn intentExtra(_: *@This()) ?[]const u8 { + return null; + } + pub fn debuggable(_: *@This()) bool { + return false; + } + pub fn vulkanDisabled(self: *@This()) bool { + return checkNew(self.fs, .{ .version_code = self.version, .setting = self.meta.guardSetting() }); + } + pub fn metaData(self: *@This()) ?[]const u8 { + return self.meta.metaData(); + } + pub fn hasVulkan(_: *@This()) bool { + return true; + } + + fn run(fs: *FakeFs, meta: renderer.MetaRead, version: ?i64) renderer.Decision { + var q: LaunchQuery = .{ .fs = fs, .meta = meta, .version = version }; + return renderer.decide(&q); + } +}; + +test "a failed meta-data read preserves the marks; the next good read is still guarded" { + const R = LaunchQuery.renderer; + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.set(start_file, "1 vulkan"); // a leftover start mark too + + // Launch with a JNI read error: gles for this launch, marks untouched. + const d1 = LaunchQuery.run(&fs, .read_error, 1); + try testing.expectEqual(R.Renderer.gles, d1.renderer); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); + try testing.expectEqualStrings("1 vulkan", fs.start.?); + + // Next launch reads the ORIGINAL setting: still disabled by the guard. + const d2 = LaunchQuery.run(&fs, .{ .value = "vulkan" }, 1); + try testing.expectEqual(R.Renderer.gles, d2.renderer); + try testing.expectEqual(R.Source.crash_guard, d2.source); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); +} + +test "a failed meta-data read also keeps a pending start mark for the next good read" { + const R = LaunchQuery.renderer; + var fs: FakeFs = .{}; + fs.set(start_file, "1 auto"); // crashed on an auto-chosen Vulkan + _ = LaunchQuery.run(&fs, .read_error, 1); + try testing.expectEqualStrings("1 auto", fs.start.?); + try testing.expect(fs.disabled == null); + const d = LaunchQuery.run(&fs, .{ .value = "auto" }, 1); + try testing.expectEqual(R.Source.crash_guard, d.source); + try testing.expectEqualStrings("1 auto", fs.disabled.?); +} + +test "a failed versionCode read preserves the marks; the next good read is still guarded" { + const R = LaunchQuery.renderer; + var fs: FakeFs = .{}; + fs.set(disabled_file, "42 vulkan"); + + // versionCode unreadable: guarded this launch, marks untouched. + const d1 = LaunchQuery.run(&fs, .{ .value = "vulkan" }, null); + try testing.expectEqual(R.Renderer.gles, d1.renderer); + try testing.expectEqual(R.Source.crash_guard, d1.source); + try testing.expectEqualStrings("42 vulkan", fs.disabled.?); + + const d2 = LaunchQuery.run(&fs, .{ .value = "vulkan" }, 42); + try testing.expectEqual(R.Source.crash_guard, d2.source); + try testing.expectEqualStrings("42 vulkan", fs.disabled.?); + + // Same for a pending start mark. + var fs2: FakeFs = .{}; + fs2.set(start_file, "42 vulkan"); + try testing.expect(checkNew(&fs2, .{ .version_code = null, .setting = "vulkan" })); + try testing.expectEqualStrings("42 vulkan", fs2.start.?); + try testing.expect(fs2.disabled == null); + const d3 = LaunchQuery.run(&fs2, .{ .value = "vulkan" }, 42); + try testing.expectEqual(R.Source.crash_guard, d3.source); + try testing.expectEqualStrings("42 vulkan", fs2.disabled.?); +} + +test "an observed change still resets even when the OTHER half is unreadable" { + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + // A successfully read new version, setting unreadable: a real change. + try testing.expect(!checkNew(&fs, .{ .version_code = 2, .setting = null })); + try testing.expect(fs.disabled == null); + fs.set(disabled_file, "1 vulkan"); + // A successfully read different setting, version unreadable. + try testing.expect(!checkNew(&fs, .{ .version_code = null, .setting = "gles" })); + try testing.expect(fs.disabled == null); +} + +test "an absent key is a successful read of the default gles: a real change vs a vulkan stamp" { + const R = LaunchQuery.renderer; + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.set(start_file, "1 vulkan"); + const d = LaunchQuery.run(&fs, .absent, 1); + try testing.expectEqual(R.Renderer.gles, d.renderer); + try testing.expectEqual(R.Source.setting, d.source); // not the guard + try testing.expect(fs.disabled == null and fs.start == null); // reset +} + +// ── Codex bot findings on labelle-android#33 (final round) ───────────── + +test "same-process relaunch before stable supersedes its own mark (no disable)" { + var fs: FakeFs = .{}; + var state: StartState = .{}; // ONE process + try testing.expect(!checkObserved(&fs, Observed.of(v1_vulkan), &state)); + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, NoSpawner{})); + const first = state.generation; + try testing.expect(state.owned); + + // The Activity is relaunched in the same process 3 s later: its own live + // mark is not a crash. + try testing.expect(!checkObserved(&fs, Observed.of(v1_vulkan), &state)); + try testing.expect(fs.disabled == null); + try testing.expectEqualStrings("1 vulkan", fs.start.?); + // It supersedes: new generation, new mark. + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, NoSpawner{})); + try testing.expectEqual(first + 1, state.generation); + + // The old timer, even when stable, cannot delete the new mark. + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock }; + settle(&fs, &clock, &frames, &state, first, null); + try testing.expectEqualStrings("1 vulkan", fs.start.?); + try testing.expect(state.owned); + // The new one can, and gives up ownership. + var clock2: FakeClock = .{}; + var frames2: FakeFrames = .{ .clock = &clock2 }; + settle(&fs, &clock2, &frames2, &state, first + 1, null); + try testing.expect(fs.start == null); + try testing.expect(!state.owned); +} + +test "a mark left by a different (dead) process is still a crash" { + var fs: FakeFs = .{}; + var dead: StartState = .{}; + try testing.expect(beginStart(&fs, v1_vulkan, &dead, NoFrames{}, NoSpawner{})); + // The process dies; a NEW process (fresh state) launches. + var next: StartState = .{}; + try testing.expect(checkObserved(&fs, Observed.of(v1_vulkan), &next)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); + try testing.expect(fs.start == null); +} + +test "frame baseline: a hung new start is not cleared by the previous Activity's frames" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + var clock: FakeClock = .{ .dies_at_ns = 60 * std.time.ns_per_s }; + // The previous Activity presented 600 frames; the new init hangs, so the + // counter stays at 600 (never reset). + var frames: FakeFrames = .{ .clock = &clock, .init_done_ns = null, .before_init = 600 }; + var got: ?u32 = null; + var baseline: ?u64 = null; + try testing.expect(beginStart(&fs, v1_vulkan, &state, &frames, RecordingSpawner{ .got = &got, .baseline = &baseline })); + try testing.expectEqual(@as(?u64, 600), baseline); + settle(&fs, &clock, &frames, &state, got.?, baseline); + try testing.expect(clock.now_ns >= stable_after_ns); + try testing.expect(fs.start != null); // not cleared: the process is killed with the mark +} + +test "frame baseline: a counter reset by the new init counts from 0" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + var clock: FakeClock = .{}; + // 600 old frames; the new init completes at 1 s (counter → 0), then 60 fps. + var frames: FakeFrames = .{ .clock = &clock, .init_done_ns = std.time.ns_per_s, .before_init = 600 }; + var got: ?u32 = null; + var baseline: ?u64 = null; + try testing.expect(beginStart(&fs, v1_vulkan, &state, &frames, RecordingSpawner{ .got = &got, .baseline = &baseline })); + settle(&fs, &clock, &frames, &state, got.?, baseline); + try testing.expect(fs.start == null); + try testing.expectEqual(stable_after_ns, clock.now_ns); // 540 new frames by then + + // framesSince itself: under the baseline = reset (sticky), else the delta. + var b: ?u64 = 600; + try testing.expectEqual(@as(u64, 0), framesSince(600, &b)); + try testing.expectEqual(@as(u64, 10), framesSince(610, &b)); + try testing.expectEqual(@as(u64, 5), framesSince(5, &b)); + try testing.expectEqual(@as(?u64, 0), b); + try testing.expectEqual(@as(u64, 700), framesSince(700, &b)); + var none: ?u64 = null; + try testing.expectEqual(@as(u64, 42), framesSince(42, &none)); +} + +test "a version change deletes only the mismatching marker; a matching start mark still counts" { + // v1 disabled (stale); an intent launch on v2 wrote a v2 start mark and + // crashed. The next ordinary v2 launch must still be guarded. + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.set(start_file, "2 vulkan"); + try testing.expect(checkAtLaunch(&fs, v2_vulkan)); + try testing.expectEqualStrings("2 vulkan", fs.disabled.?); // rewritten for v2 + try testing.expect(fs.start == null); + + // A stale start mark with a still-matching disabled mark: only the start + // mark goes. + var fs2: FakeFs = .{}; + fs2.set(disabled_file, "2 vulkan"); + fs2.set(start_file, "1 vulkan"); + try testing.expect(checkAtLaunch(&fs2, v2_vulkan)); + try testing.expectEqualStrings("2 vulkan", fs2.disabled.?); + try testing.expect(fs2.start == null); +} + +test "a malformed disabled mark keeps the guard and does not lose a kept start mark" { + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vul"); // a partial write from an older build + fs.set(start_file, "1 vulkan"); + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); // rewritten + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); // and stays guarded +} + +test "atomic writes: a partial disabled write leaves no malformed marker" { + var fs: FakeFs = .{}; + fs.set(start_file, "1 vulkan"); + fs.partial_writes = true; // ENOSPC mid-write + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expect(fs.disabled == null); // never a truncated marker + try testing.expect(fs.disabled_tmp == null); // the tmp is cleaned up + try testing.expectEqualStrings("1 vulkan", fs.start.?); // kept (fail closed) + // Space comes back: the next launch converts it. + fs.partial_writes = false; + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); + try testing.expect(fs.start == null); +} + +test "a start mark that cannot be written falls back to gles (unless the intent asked)" { + const R = @import("renderer.zig"); + var fs: FakeFs = .{}; + var state: StartState = .{}; + fs.partial_writes = true; + var calls: usize = 0; + const CountingSpawner = struct { + calls: *usize, + pub fn spawn(self: @This(), _: u32, _: ?u64) error{}!void { + self.calls.* += 1; + } + }; + const recorded = beginStart(&fs, v1_vulkan, &state, NoFrames{}, CountingSpawner{ .calls = &calls }); + try testing.expect(!recorded); + try testing.expect(fs.start == null and fs.start_tmp == null); + try testing.expectEqual(@as(usize, 0), calls); // no timer for no mark + try testing.expect(!state.owned); + + const d = R.afterStart(.{ .renderer = .vulkan, .source = .setting }, recorded); + try testing.expectEqual(R.Renderer.gles, d.renderer); + try testing.expectEqual(R.Source.crash_guard_unrecorded, d.source); + const a = R.afterStart(.{ .renderer = .vulkan, .source = .auto }, recorded); + try testing.expectEqual(R.Renderer.gles, a.renderer); + // The intent override keeps Vulkan; a recorded start changes nothing. + const i = R.afterStart(.{ .renderer = .vulkan, .source = .intent }, recorded); + try testing.expectEqual(R.Renderer.vulkan, i.renderer); + const ok = R.afterStart(.{ .renderer = .vulkan, .source = .setting }, true); + try testing.expectEqual(R.Renderer.vulkan, ok.renderer); + try testing.expectEqual(R.Source.setting, ok.source); +} + +// ── Codex VERDICT at 7c28035 (labelle-bgfx#172 comments 5909398772 / 5909522964) ── + +test "GLES same-process relaunch: the old Vulkan timer can no longer clear the mark" { + // Vulkan start (gen N, mark written) → the Activity is relaunched in the + // same process and resolves to GLES → GLES init resets the counter and + // its frames pass 120. The gen-N thread must NOT clear the Vulkan mark. + var fs: FakeFs = .{}; + var state: StartState = .{}; + supersede(&state); // resolution 1 + var got: ?u32 = null; + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, RecordingSpawner{ .got = &got })); + const vulkan_gen = got.?; + + // Resolution 2 (GLES): only supersede, no beginStart. + var clock: FakeClock = .{}; + const Relaunch = struct { + inner: FakeFrames, + state: *StartState, + pub fn framesPresented(self: *@This()) ?u64 { + // The GLES relaunch lands on the 2nd poll (0.5 s). + if (self.inner.calls == 1) supersede(self.state); + return self.inner.framesPresented(); + } + pub fn noteTimeOnly(self: *@This()) void { + self.inner.noteTimeOnly(); + } + }; + // GLES init at 1 s resets the counter, then 60 fps (540 frames by 10 s). + var frames: Relaunch = .{ .inner = .{ .clock = &clock, .init_done_ns = std.time.ns_per_s }, .state = &state }; + settle(&fs, &clock, &frames, &state, vulkan_gen, 0); + try testing.expect(clock.now_ns < stable_after_ns); // it stopped early... + try testing.expectEqualStrings("1 vulkan", fs.start.?); // ...and kept the mark + try testing.expect(state.owned); // still this process's incomplete start + + // The process later exits normally without another Vulkan start: the + // next process treats the Vulkan start as incomplete. + try testing.expect(checkAtLaunch(&fs, v1_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); +} + +test "a later Vulkan start in the same process can still clear the mark itself" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, NoSpawner{})); + supersede(&state); // a GLES relaunch + // Vulkan again (same process): its own mark is not a crash... + try testing.expect(!checkObserved(&fs, Observed.of(v1_vulkan), &state)); + var got: ?u32 = null; + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, RecordingSpawner{ .got = &got })); + // ...and once THIS start is stable, it clears the mark. + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock }; + settle(&fs, &clock, &frames, &state, got.?, null); + try testing.expect(fs.start == null); + try testing.expect(!state.owned); +} + +test "a marker read error fails closed: guarded, nothing written or deleted" { + // Disabled marker unreadable (EIO): guarded even though a start-free, + // version-bumped launch would otherwise reset. + var fs: FakeFs = .{}; + fs.set(disabled_file, "1 vulkan"); + fs.read_error_on = disabled_file; + try testing.expect(checkAtLaunch(&fs, v2_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs.disabled.?); // not deleted + + // Start marker unreadable, with a stale disabled marker that would reset. + var fs2: FakeFs = .{}; + fs2.set(disabled_file, "1 vulkan"); + fs2.set(start_file, "2 vulkan"); + fs2.read_error_on = start_file; + try testing.expect(checkAtLaunch(&fs2, v2_vulkan)); + try testing.expectEqualStrings("1 vulkan", fs2.disabled.?); // untouched + try testing.expectEqualStrings("2 vulkan", fs2.start.?); + + // Readable again: the normal rules apply (the v2 start is a crash). + fs2.read_error_on = null; + try testing.expect(checkAtLaunch(&fs2, v2_vulkan)); + try testing.expectEqualStrings("2 vulkan", fs2.disabled.?); +} + +test "a failed unlink keeps ownership and retries; stable start is not left 'incomplete'" { + var fs: FakeFs = .{}; + var state: StartState = .{}; + var got: ?u32 = null; + try testing.expect(beginStart(&fs, v1_vulkan, &state, NoFrames{}, RecordingSpawner{ .got = &got })); + fs.failing_deletes = 3; // three transient unlink failures + var clock: FakeClock = .{}; + var frames: FakeFrames = .{ .clock = &clock }; + settle(&fs, &clock, &frames, &state, got.?, null); + try testing.expect(fs.start == null); // cleared on the 4th attempt + try testing.expect(!state.owned); + try testing.expectEqual(stable_after_ns + 3 * poll_interval_ns, clock.now_ns); + + // While it keeps failing, ownership is kept, so a same-process relaunch + // does not read this stable start as a crash. + var fs2: FakeFs = .{}; + var state2: StartState = .{}; + got = null; + try testing.expect(beginStart(&fs2, v1_vulkan, &state2, NoFrames{}, RecordingSpawner{ .got = &got })); + fs2.failing_deletes = std.math.maxInt(usize); + var clock2: FakeClock = .{ .dies_at_ns = 20 * std.time.ns_per_s }; + var frames2: FakeFrames = .{ .clock = &clock2 }; + settle(&fs2, &clock2, &frames2, &state2, got.?, null); + try testing.expect(state2.owned); + try testing.expect(fs2.start != null); + fs2.failing_deletes = 0; + try testing.expect(!checkObserved(&fs2, Observed.of(v1_vulkan), &state2)); + try testing.expect(fs2.disabled == null); +} + +test "both marker dirs are checked; writes go only to the primary" { + // A disabled marker in the OTHER dir (older build / fallback launch). + var primary: FakeFs = .{}; + var other: FakeFs = .{}; + other.set(disabled_file, "1 vulkan"); + var st: StartState = .{}; + try testing.expect(checkDirs(&primary, &other, Observed.of(v1_vulkan), &st)); + try testing.expect(primary.disabled == null); // nothing copied/written + try testing.expectEqualStrings("1 vulkan", other.disabled.?); + + // A crashed start in the other dir: the disabled marker goes to the + // primary, the start mark is removed from the other dir. + var p2: FakeFs = .{}; + var o2: FakeFs = .{}; + o2.set(start_file, "1 vulkan"); + var st2: StartState = .{}; + try testing.expect(checkDirs(&p2, &o2, Observed.of(v1_vulkan), &st2)); + try testing.expectEqualStrings("1 vulkan", p2.disabled.?); + try testing.expect(o2.start == null and o2.disabled == null); + + // A read error in the other dir fails closed too. + var p3: FakeFs = .{}; + var o3: FakeFs = .{ .read_error_on = start_file }; + var st3: StartState = .{}; + try testing.expect(checkDirs(&p3, &o3, Observed.of(v1_vulkan), &st3)); + + // A stale marker in the other dir is reset there; nothing else happens. + var p4: FakeFs = .{}; + var o4: FakeFs = .{}; + o4.set(disabled_file, "1 vulkan"); + var st4: StartState = .{}; + try testing.expect(!checkDirs(&p4, &o4, Observed.of(v2_vulkan), &st4)); + try testing.expect(o4.disabled == null); + + // The primary's OWN live mark is skipped (same process), but a start + // mark in the other dir is never "owned". + var p5: FakeFs = .{}; + var o5: FakeFs = .{}; + var st5: StartState = .{}; + try testing.expect(beginStart(&p5, v1_vulkan, &st5, NoFrames{}, NoSpawner{})); + try testing.expect(!checkDirs(&p5, &o5, Observed.of(v1_vulkan), &st5)); + o5.set(start_file, "1 vulkan"); + try testing.expect(checkDirs(&p5, &o5, Observed.of(v1_vulkan), &st5)); } -test "stub: Vulkan is never disabled" { - try std.testing.expect(!isVulkanDisabled(null)); - try std.testing.expect(!isVulkanDisabled(@ptrFromInt(0x1000))); +test "siblingNoBackup derives /no_backup from internalDataPath" { + var buf: [128]u8 = undefined; + try testing.expectEqualStrings("/data/user/0/com.x/no_backup", siblingNoBackup("/data/user/0/com.x/files", &buf).?); + try testing.expectEqualStrings("/data/user/0/com.x/no_backup", siblingNoBackup("/data/user/0/com.x/files/", &buf).?); + try testing.expect(siblingNoBackup("/files", &buf) == null); + try testing.expect(siblingNoBackup("files", &buf) == null); } diff --git a/src/jni/renderer_query.c b/src/jni/renderer_query.c index 6fd9174..ce6f542 100644 --- a/src/jni/renderer_query.c +++ b/src/jni/renderer_query.c @@ -11,6 +11,9 @@ // * `auto`'s device check: // getPackageManager().hasSystemFeature("android.hardware.vulkan.version", // 0x401000) // Vulkan 1.1 +// * for the crash guard (labelle-android#28): the package's version code +// and `ANativeActivity.internalDataPath`, plus (not JNI) the runtime +// `dlsym` of labelle-bgfx's frame counter and a monotonic clock // // In C for the same reason as intent_extras.c: already declares the // JNI vtables. Off Android this is an empty TU. @@ -18,8 +21,11 @@ #include #include +#include #include #include +#include +#include // android.content.pm.PackageManager.GET_META_DATA (API 1). #define LABELLE_GET_META_DATA 0x00000080 @@ -42,6 +48,10 @@ static jobject package_manager(JNIEnv *env, jobject activity) { // Copy the `labelle.renderer` meta-data string into `buf` (NUL-terminated). // Returns its length (>= 0), -1 when the key is absent (or not a string), // -2 on any JNI failure, -3 when the value does not fit in `buf_cap`. +// -1 (absent) and -2 (failure) MUST stay distinct: `renderer.MetaRead` maps +// -1 to the default `gles` but -2 to "unknown", and the crash guard keeps its +// marks on unknown rather than reading a transient failure as a setting +// change (labelle-android#33 review). // Never leaves a Java exception pending and never leaks a local ref. // Callable from any thread (attached or not). int labelle_android_read_renderer_meta(const void *activity_ptr, char *buf, size_t buf_cap) { @@ -155,4 +165,158 @@ int labelle_android_has_system_feature(const void *activity_ptr, const char *nam return has; } +// `activity.getNoBackupFilesDir().getAbsolutePath()` (API 21) into `buf` +// (NUL-terminated): the crash guard's marker directory (labelle-android#28), +// which Android Auto Backup and device-to-device transfer never copy. +// Returns its length (> 0), -1 on any JNI failure, -3 when it does not fit +// `buf_cap`. Never leaves a Java exception pending and never leaks a local +// ref. Callable from any thread (attached or not). +int labelle_android_no_backup_dir(const void *activity_ptr, char *buf, size_t buf_cap) { + const ANativeActivity *na = (const ANativeActivity *)activity_ptr; + if (na == NULL || na->vm == NULL || na->clazz == NULL || buf == NULL || buf_cap == 0) return -1; + JavaVM *vm = na->vm; + jobject activity = na->clazz; + + int we_attached = 0; + JNIEnv *env = labelle_android_acquire_env(vm, &we_attached); + if (env == NULL) return -1; + + int result = -1; + if ((*env)->PushLocalFrame(env, 8) == JNI_OK) { + jclass activity_cls = (*env)->GetObjectClass(env, activity); + jmethodID get_dir = (activity_cls && !(*env)->ExceptionCheck(env)) ? (*env)->GetMethodID(env, activity_cls, "getNoBackupFilesDir", "()Ljava/io/File;") : NULL; + jobject dir = (get_dir && !(*env)->ExceptionCheck(env)) ? (*env)->CallObjectMethod(env, activity, get_dir) : NULL; + jclass file_cls = (dir && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, dir) : NULL; + jmethodID get_path = (file_cls && !(*env)->ExceptionCheck(env)) ? (*env)->GetMethodID(env, file_cls, "getAbsolutePath", "()Ljava/lang/String;") : NULL; + jstring jpath = (get_path && !(*env)->ExceptionCheck(env)) ? (jstring)(*env)->CallObjectMethod(env, dir, get_path) : NULL; + if (jpath != NULL && !(*env)->ExceptionCheck(env)) { + jsize chars = (*env)->GetStringLength(env, jpath); + jsize bytes = (*env)->GetStringUTFLength(env, jpath); + if (bytes <= 0) { + result = -1; + } else if ((size_t)bytes + 1 > buf_cap) { + result = -3; + } else { + (*env)->GetStringUTFRegion(env, jpath, 0, chars, buf); + if (!(*env)->ExceptionCheck(env)) { + buf[bytes] = 0; + result = (int)bytes; + } + } + } + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + result = -1; + } + (*env)->PopLocalFrame(env, NULL); + } else if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + __android_log_print(ANDROID_LOG_WARN, "labelle-android", "no-backup dir: PushLocalFrame failed; exception cleared"); + } + + if (we_attached) (*vm)->DetachCurrentThread(vm); + return result; +} + +// `ANativeActivity.internalDataPath` (the app's private files dir), or NULL. +// The crash guard's fallback marker directory (labelle-android#28) when +// `labelle_android_no_backup_dir` fails. +const char *labelle_android_internal_data_path(const void *activity_ptr) { + const ANativeActivity *na = (const ANativeActivity *)activity_ptr; + return na != NULL ? na->internalDataPath : NULL; +} + +// The running package's version code (labelle-android#28: a new app version +// resets the crash guard): +// getPackageManager().getPackageInfo(getPackageName(), 0) +// .getLongVersionCode() // API 28+ +// .versionCode // older: NoSuchMethodError -> the int field +// Writes it to `*out` and returns 0, or returns -1 on any JNI failure +// (`*out` untouched; the Zig side treats it as "unknown", never as a +// version, so a failure cannot reset the crash guard). Never leaves a Java exception pending and never leaks a +// local ref. Callable from any thread (attached or not). +int labelle_android_version_code(const void *activity_ptr, long long *out) { + const ANativeActivity *na = (const ANativeActivity *)activity_ptr; + if (na == NULL || na->vm == NULL || na->clazz == NULL || out == NULL) return -1; + JavaVM *vm = na->vm; + jobject activity = na->clazz; + + int we_attached = 0; + JNIEnv *env = labelle_android_acquire_env(vm, &we_attached); + if (env == NULL) return -1; + + int result = -1; + if ((*env)->PushLocalFrame(env, 16) == JNI_OK) { + jobject pm = package_manager(env, activity); + jclass activity_cls = (pm != NULL && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, activity) : NULL; + jmethodID get_pkg = activity_cls ? (*env)->GetMethodID(env, activity_cls, "getPackageName", "()Ljava/lang/String;") : NULL; + jstring pkg = (get_pkg && !(*env)->ExceptionCheck(env)) ? (jstring)(*env)->CallObjectMethod(env, activity, get_pkg) : NULL; + jclass pm_cls = (pkg && !(*env)->ExceptionCheck(env)) ? (*env)->GetObjectClass(env, pm) : NULL; + // The (String, int) overload: deprecated from API 33 in favour of + // the PackageInfoFlags one, but still present and working. + jmethodID get_pkg_info = pm_cls ? (*env)->GetMethodID(env, pm_cls, "getPackageInfo", "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;") : NULL; + // Throws NameNotFoundException only if our own package vanished. + jobject pkg_info = (get_pkg_info && !(*env)->ExceptionCheck(env)) ? (*env)->CallObjectMethod(env, pm, get_pkg_info, pkg, (jint)0) : NULL; + if (pkg_info != NULL && !(*env)->ExceptionCheck(env)) { + jclass pi_cls = (*env)->GetObjectClass(env, pkg_info); + if (pi_cls != NULL && !(*env)->ExceptionCheck(env)) { + jmethodID get_long = (*env)->GetMethodID(env, pi_cls, "getLongVersionCode", "()J"); + if (get_long != NULL && !(*env)->ExceptionCheck(env)) { + jlong v = (*env)->CallLongMethod(env, pkg_info, get_long); + if (!(*env)->ExceptionCheck(env)) { + *out = (long long)v; + result = 0; + } + } else { + // Below API 28: NoSuchMethodError. Clear it and read the + // (deprecated) int field instead. + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + jfieldID vc_fid = (*env)->GetFieldID(env, pi_cls, "versionCode", "I"); + if (vc_fid != NULL && !(*env)->ExceptionCheck(env)) { + jint v = (*env)->GetIntField(env, pkg_info, vc_fid); + if (!(*env)->ExceptionCheck(env)) { + *out = (long long)v; + result = 0; + } + } + } + } + } + // Any JNI call above may have raised; clear before popping so we + // never hand a pending exception back to the caller's thread. + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + result = -1; + } + (*env)->PopLocalFrame(env, NULL); + } else if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + __android_log_print(ANDROID_LOG_WARN, "labelle-android", "version code: PushLocalFrame failed; exception cleared"); + } + + if (we_attached) (*vm)->DetachCurrentThread(vm); + return result; +} + +// The crash guard's stable rule (labelle-android#28) counts presented frames +// through labelle-bgfx's generic export (labelle-toolkit/labelle-bgfx#182): +// uint64_t labelle_bgfx_frames_presented(void); +// looked up at RUNTIME, so labelle-android has no build dependency on +// labelle-bgfx and still links for other backends. Writes the count to +// `*out` and returns 1, or returns 0 when the symbol is not exported. +int labelle_android_frames_presented(uint64_t *out) { + typedef uint64_t (*frames_fn)(void); + frames_fn fn = (frames_fn)dlsym(RTLD_DEFAULT, "labelle_bgfx_frames_presented"); + if (fn == NULL || out == NULL) return 0; + *out = fn(); + return 1; +} + +// CLOCK_MONOTONIC in nanoseconds (0 if the clock is unavailable). +uint64_t labelle_android_monotonic_ns(void) { + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0; + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} + #endif /* __ANDROID__ */ diff --git a/src/renderer.zig b/src/renderer.zig index 8916edd..8fd9836 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -10,7 +10,9 @@ //! only in a debuggable apk (the same gate as the other debuggable-only //! keys in `intent_env.zig`). Anything else is warned about and ignored. //! 2. `crash-guard`: Vulkan disabled after a crashed Vulkan start (D11, -//! `crash_guard.zig`; a stub until labelle-android#28) → `gles`. +//! `crash_guard.zig`, labelle-android#28) → `gles`. The guard is keyed +//! on the app's versionCode and the effective setting (rule 3), so a +//! new version or a changed setting tries Vulkan again. //! 3. `setting`: the provider setting, stamped into the manifest as //! `` //! (labelle-android#26). Missing or unreadable → `gles`; an invalid @@ -18,8 +20,14 @@ //! 4. `auto` (the setting's third value): Vulkan when the device reports //! `android.hardware.vulkan.version` >= 1.1 (0x401000), else `gles`. //! -//! Then `setenv("LABELLE_BGFX_RENDERER", "vulkan"|"gles", 1)` and one log -//! line: `renderer: (source: intent|crash-guard|setting|auto)`. +//! Then, when the result is `vulkan`, `crash_guard.beginVulkanStart` (the +//! start mark + the stable thread: 120 frames and 10 s). If the start mark +//! cannot be recorded, `afterStart` switches this launch to `gles` (fail +//! closed; not for an intent override). Then +//! `setenv("LABELLE_BGFX_RENDERER", "vulkan"|"gles", 1)` and one log line: +//! `renderer: (source: intent|crash-guard|setting|auto)`, with +//! `; previous Vulkan start did not complete` (or `; could not record the +//! Vulkan start`) after `crash-guard`. //! //! ## Where it runs //! @@ -60,24 +68,101 @@ pub const Renderer = enum { pub const Source = enum { intent, crash_guard, + /// The guard could not record the Vulkan start (no start mark), so this + /// launch stays off Vulkan (`afterStart`). + crash_guard_unrecorded, setting, auto, pub fn label(self: Source) []const u8 { return switch (self) { .intent => "intent", - .crash_guard => "crash-guard", + .crash_guard, .crash_guard_unrecorded => "crash-guard", .setting => "setting", .auto => "auto", }; } + + /// Extra context after the label in the log line ("" for none). + pub fn detail(self: Source) []const u8 { + return switch (self) { + .crash_guard => "; previous Vulkan start did not complete", + .crash_guard_unrecorded => "; could not record the Vulkan start", + else => "", + }; + } }; pub const Decision = struct { renderer: Renderer, source: Source }; +/// After `crash_guard.beginVulkanStart` for a `vulkan` decision: when the +/// start mark could NOT be recorded (`recorded` false), a Vulkan crash would +/// go unnoticed, so the launch falls back to `gles` (fail closed). An intent +/// override is a developer's explicit request and keeps Vulkan (warned). +pub fn afterStart(d: Decision, recorded: bool) Decision { + if (d.renderer != .vulkan or recorded) return d; + if (d.source == .intent) { + std.log.warn("android: crash guard: Vulkan start not recorded; keeping vulkan for the intent override", .{}); + return d; + } + return .{ .renderer = .gles, .source = .crash_guard_unrecorded }; +} + /// The provider setting's values. pub const Setting = enum { gles, vulkan, auto }; +/// The three (four) outcomes of the `labelle.renderer` meta-data read. The +/// JNI helper's return code carries them across the C boundary: +/// `>= 0` = value length, `-1` = absent, `-3` = too long, `-2` (or any +/// other negative) = read error. +pub const MetaRead = union(enum) { + /// Read successfully (may still be an invalid value; `decide` warns). + value: []const u8, + /// Read successfully; the key is not there → the default, `gles`. + absent, + /// Read successfully; the value does not fit the buffer (so it is not a + /// valid setting) → `gles`, like any invalid value. + too_long, + /// The JNI walk failed: the setting is UNKNOWN. + read_error, + + pub fn fromCode(n: c_int, buf: []const u8) MetaRead { + if (n >= 0) return .{ .value = buf[0..@intCast(n)] }; + return switch (n) { + -1 => .absent, + -3 => .too_long, + else => .read_error, + }; + } + + /// What the setting rule sees: the value, or null (→ `gles`) otherwise. + /// For the RENDERER, a read error still means gles, as before. + pub fn metaData(self: MetaRead) ?[]const u8 { + return switch (self) { + .value => |v| v, + else => null, + }; + } + + /// The setting the crash guard compares/records: the effective setting + /// for every SUCCESSFUL read (absent/invalid/too long → `gles`), and null + /// for a read error, which the guard treats as unknown (marks kept). + pub fn guardSetting(self: MetaRead) ?[]const u8 { + return switch (self) { + .value => |v| settingLabel(v), + .absent, .too_long => "gles", + .read_error => null, + }; + } +}; + +/// The effective setting the crash guard's stamp records: the parsed +/// meta-data, and `gles` for missing or invalid (which is what they mean). +pub fn settingLabel(meta: ?[]const u8) []const u8 { + const raw = meta orelse return "gles"; + return @tagName(parseSetting(raw) orelse .gles); +} + /// An explicit renderer (the intent extra). Exact, lower-case match only. pub fn parseRenderer(value: []const u8) ?Renderer { if (std.mem.eql(u8, value, "vulkan")) return .vulkan; @@ -109,7 +194,9 @@ pub fn decide(q: anytype) Decision { // An empty extra counts as absent, as it does in `intent_env`. if (v.len > 0) { if (!q.debuggable()) { - std.log.info("android: ignoring intent extra {s}: the apk is not debuggable", .{env_name}); + // Not logged here: `launch_intent.apply` (intent_env's + // debuggable-only gate) already logs "ignoring intent extra + // LABELLE_BGFX_RENDERER: the apk is not debuggable" once. } else if (parseRenderer(v)) |r| { return .{ .renderer = r, .source = .intent }; } else { @@ -144,6 +231,9 @@ const JniQuery = struct { activity: *const anyopaque, extra: ?[:0]const u8, meta_buf: [64]u8 = undefined, + /// The meta-data is asked for by the guard (for its stamp) and by the + /// setting rule; the JNI walk (and its log line) happens once. + meta: ?MetaRead = null, pub fn intentExtra(self: *JniQuery) ?[]const u8 { return self.extra; @@ -152,25 +242,23 @@ const JniQuery = struct { return debuggable_mod.isDebuggable(self.activity); } pub fn vulkanDisabled(self: *JniQuery) bool { - return crash_guard.isVulkanDisabled(self.activity); + return crash_guard.vulkanDisabled(self.activity, self.metaRead().guardSetting()); } pub fn metaData(self: *JniQuery) ?[]const u8 { + return self.metaRead().metaData(); + } + fn metaRead(self: *JniQuery) MetaRead { + if (self.meta) |m| return m; const n = labelle_android_read_renderer_meta(self.activity, &self.meta_buf, self.meta_buf.len); - switch (n) { - -1 => { - std.log.info("android: no {s} meta-data; using gles", .{meta_data_name}); - return null; - }, - -3 => { - std.log.warn("android: {s} meta-data too long; using gles", .{meta_data_name}); - return null; - }, - else => if (n < 0) { - std.log.warn("android: could not read the {s} meta-data; using gles", .{meta_data_name}); - return null; - }, + const m = MetaRead.fromCode(n, &self.meta_buf); + switch (m) { + .value => {}, + .absent => std.log.info("android: no {s} meta-data; using gles", .{meta_data_name}), + .too_long => std.log.warn("android: {s} meta-data too long; using gles", .{meta_data_name}), + .read_error => std.log.warn("android: could not read the {s} meta-data; using gles (crash-guard marks kept)", .{meta_data_name}), } - return self.meta_buf[0..@intCast(n)]; + self.meta = m; + return m; } pub fn hasVulkan(self: *JniQuery) bool { return labelle_android_has_system_feature(self.activity, "android.hardware.vulkan.version", vulkan_1_1) != 0; @@ -185,12 +273,16 @@ const JniQuery = struct { pub fn resolve(activity: ?*const anyopaque, intent_extra: ?[:0]const u8) void { if (comptime !is_android) return; const a = activity orelse return; + // Every resolution supersedes this process's earlier Vulkan stable + // thread (whatever it picks), keeping its start mark "incomplete". + crash_guard.beginResolution(); var q: JniQuery = .{ .activity = a, .extra = intent_extra }; - const d = decide(&q); + var d = decide(&q); + if (d.renderer == .vulkan) d = afterStart(d, crash_guard.beginVulkanStart(a, q.metaRead().guardSetting())); if (setenv(env_name.ptr, d.renderer.envValue().ptr, 1) != 0) { std.log.warn("android: could not set {s}={s}", .{ env_name, d.renderer.envValue() }); } - std.log.info("renderer: {s} (source: {s})", .{ d.renderer.envValue(), d.source.label() }); + std.log.info("renderer: {s} (source: {s}{s})", .{ d.renderer.envValue(), d.source.label(), d.source.detail() }); } // ── Tests ─────────────────────────────────────────────────────────────── @@ -324,6 +416,48 @@ test "env values and log labels" { try testing.expectEqualStrings("intent", Source.intent.label()); try testing.expectEqualStrings("setting", Source.setting.label()); try testing.expectEqualStrings("auto", Source.auto.label()); + try testing.expectEqualStrings("; previous Vulkan start did not complete", Source.crash_guard.detail()); + try testing.expectEqualStrings("crash-guard", Source.crash_guard_unrecorded.label()); + try testing.expectEqualStrings("; could not record the Vulkan start", Source.crash_guard_unrecorded.detail()); + try testing.expectEqualStrings("", Source.setting.detail()); +} + +test "MetaRead: the JNI return code's outcomes, and what each side sees" { + const buf = "vulkanXXXX"; + const v = MetaRead.fromCode(6, buf); + try testing.expectEqualStrings("vulkan", v.value); + try testing.expectEqualStrings("vulkan", v.metaData().?); + try testing.expectEqualStrings("vulkan", v.guardSetting().?); + + // Absent: a successful read → default gles for both sides. + const a = MetaRead.fromCode(-1, buf); + try testing.expect(a == .absent); + try testing.expect(a.metaData() == null); + try testing.expectEqualStrings("gles", a.guardSetting().?); + + // Too long: an (invalid) value → gles. + const t = MetaRead.fromCode(-3, buf); + try testing.expect(t == .too_long); + try testing.expectEqualStrings("gles", t.guardSetting().?); + + // Read error: the renderer still gets gles, the guard gets UNKNOWN. + inline for (.{ -2, -7 }) |code| { + const e = MetaRead.fromCode(code, buf); + try testing.expect(e == .read_error); + try testing.expect(e.metaData() == null); + try testing.expect(e.guardSetting() == null); + } + + // An invalid value is still a successful read: effective gles. + try testing.expectEqualStrings("gles", MetaRead.fromCode(5, "metal").guardSetting().?); +} + +test "settingLabel: the effective setting for the crash guard's stamp" { + try testing.expectEqualStrings("vulkan", settingLabel("vulkan")); + try testing.expectEqualStrings("auto", settingLabel("auto")); + try testing.expectEqualStrings("gles", settingLabel("gles")); + try testing.expectEqualStrings("gles", settingLabel(null)); + try testing.expectEqualStrings("gles", settingLabel("metal")); } test "resolve is a no-op off Android (and never touches the externs)" { diff --git a/src/root.zig b/src/root.zig index 3238c03..03152f1 100644 --- a/src/root.zig +++ b/src/root.zig @@ -18,7 +18,7 @@ //! desktop decoders share (FP#549; links `libmediandk`). //! * `renderer` — resolve the renderer at launch and export //! `LABELLE_BGFX_RENDERER` (labelle-android#27, labelle-bgfx#172); -//! `crash_guard` is its D11 seam (stub until labelle-android#28). +//! `crash_guard` is its D11 Vulkan crash guard (labelle-android#28). //! //! Every file is reached from here by relative import inside this module. //! Consumers `@import("labelle_android")` and pass the running