also looking at this
fix(android): make every Zig decline speak, and prove Zig answers
#205
8 files
+616
-255
| @@ -0,0 +1,201 @@ | ||
| 1 | //! Every way an Android native gives up has to say so. | |
| 2 | //! | |
| 3 | //! A native in `src/android_dispatch.zig` declines by returning `null` or | |
| 4 | //! `JNI_FALSE`, and the Kotlin shim then serves the action itself. That hand- | |
| 5 | //! back is by design and invisible to the page — which is also what made it | |
| 6 | //! invisible to everyone else. `getDeviceInfo` ran on its first emulator | |
| 7 | //! throwing | |
| 8 | //! | |
| 9 | //! java.lang.NoSuchFieldError: no "J" field "longVersionCode" in class | |
| 10 | //! "Landroid/content/pm/PackageInfo;" | |
| 11 | //! | |
| 12 | //! on every call, and nothing noticed: the page got an answer, the suite | |
| 13 | //! passed, and the only trace was one warning in logcat. About 170 other | |
| 14 | //! failure paths did not even leave that — `catch return null`, `catch {}` — | |
| 15 | //! so a JNI plumbing failure in any of them looked exactly like a native doing | |
| 16 | //! its job. | |
| 17 | //! | |
| 18 | //! They go through three helpers now, each with a distinct message: | |
| 19 | //! `fellThrough`, `failedWithoutFallback` and `undelivered`. The mobile E2E | |
| 20 | //! runtime leg fails on any of those lines, which is what lets it claim Zig | |
| 21 | //! *served* the calls it exercised rather than merely loaded. | |
| 22 | //! | |
| 23 | //! ## The rule | |
| 24 | //! | |
| 25 | //! No `catch` in the dispatcher may discard its error: a `catch` without a | |
| 26 | //! `|capture|` whose handler is only `return`, `return null`, | |
| 27 | //! `return jni.JNI_FALSE`, `null` or `{}` is a silent decline and fails this | |
| 28 | //! test. The compiler already refuses an unused capture, so once the error is | |
| 29 | //! captured it has to go somewhere. | |
| 30 | //! | |
| 31 | //! Two shapes stay allowed, deliberately: | |
| 32 | //! | |
| 33 | //! - `catch return .some_outcome` — returning an enum outcome that the caller | |
| 34 | //! reports. `bindNatives` does this and `JNI_OnLoad` logs every outcome. | |
| 35 | //! - `catch { ... }` with a body — a handler that does something, like the | |
| 36 | //! `saveFile` path that rejects its promise with a specific message. | |
| 37 | //! | |
| 38 | //! Scoped to the dispatcher because that is where declines to Kotlin happen. | |
| 39 | //! The bridge modules return errors with `try` and the dispatcher is what | |
| 40 | //! turns them into a decline. | |
| 41 | ||
| 42 | const std = ; | |
| 43 | const testing = std.testing; | |
| 44 | ||
| 45 | const dispatch = @embedFile("android_dispatch.zig"); | |
| 46 | ||
| 47 | const Finding = struct { | |
| 48 | line: usize, | |
| 49 | text: []const u8, | |
| 50 | }; | |
| 51 | ||
| 52 | /// The first silent decline in `source`, or null if there is none. | |
| 53 | fn firstSilentCatch(source: []const u8) ?Finding { | |
| 54 | var lines = std.mem.splitScalar(u8, source, '\n'); | |
| 55 | var number: usize = 0; | |
| 56 | while (lines.next()) |raw| { | |
| 57 | number += 1; | |
| 58 | const trimmed = std.mem.trim(u8, raw, " \t\r"); | |
| 59 | // Comments and multiline string lines describe code, they are not code. | |
| 60 | if (std.mem.startsWith(u8, trimmed, "//") or std.mem.startsWith(u8, trimmed, "\\\\")) continue; | |
| 61 | ||
| 62 | const code = withoutTrailingComment(raw); | |
| 63 | var search: usize = 0; | |
| 64 | while (std.mem.indexOfPos(u8, code, search, "catch")) |at| { | |
| 65 | search = at + "catch".len; | |
| 66 | if (!isWordAt(code, at, "catch".len)) continue; | |
| 67 | ||
| 68 | const rest = std.mem.trimStart(u8, code[search..], " \t"); | |
| 69 | if (isSilentHandler(rest)) return .{ .line = number, .text = trimmed }; | |
| 70 | } | |
| 71 | } | |
| 72 | return null; | |
| 73 | } | |
| 74 | ||
| 75 | /// Whether what follows a `catch` discards the error without a capture. | |
| 76 | fn isSilentHandler(rest: []const u8) bool { | |
| 77 | if (std.mem.startsWith(u8, rest, "|")) return false; | |
| 78 | ||
| 79 | if (std.mem.startsWith(u8, rest, "{}")) return true; | |
| 80 | if (std.mem.startsWith(u8, rest, "null")) return !continuesIdentifier(rest, "null".len); | |
| 81 | ||
| 82 | if (std.mem.startsWith(u8, rest, "return")) { | |
| 83 | if (continuesIdentifier(rest, "return".len)) return false; | |
| 84 | const value = std.mem.trimStart(u8, rest["return".len..], " \t"); | |
| 85 | // An enum outcome the caller reports, like `bindNatives`. | |
| 86 | if (std.mem.startsWith(u8, value, ".")) return false; | |
| 87 | if (value.len == 0) return true; | |
| 88 | if (value[0] == ';' or value[0] == ',' or value[0] == ')') return true; | |
| 89 | if (std.mem.startsWith(u8, value, "null")) return !continuesIdentifier(value, "null".len); | |
| 90 | if (std.mem.startsWith(u8, value, "jni.JNI_FALSE")) return !continuesIdentifier(value, "jni.JNI_FALSE".len); | |
| 91 | } | |
| 92 | return false; | |
| 93 | } | |
| 94 | ||
| 95 | fn continuesIdentifier(text: []const u8, len: usize) bool { | |
| 96 | if (text.len <= len) return false; | |
| 97 | const c = text[len]; | |
| 98 | return std.ascii.isAlphanumeric(c) or c == '_'; | |
| 99 | } | |
| 100 | ||
| 101 | fn isWordAt(text: []const u8, at: usize, len: usize) bool { | |
| 102 | const before_ok = at == 0 or !(std.ascii.isAlphanumeric(text[at - 1]) or text[at - 1] == '_'); | |
| 103 | return before_ok and !continuesIdentifier(text[at..], len); | |
| 104 | } | |
| 105 | ||
| 106 | /// `line` up to a `//` that is not inside a string literal. | |
| 107 | /// | |
| 108 | /// URLs are why this is not `indexOf("//")`: the dispatcher builds | |
| 109 | /// `https://` strings, and cutting there would hide any `catch` after one. | |
| 110 | fn withoutTrailingComment(line: []const u8) []const u8 { | |
| 111 | var in_string = false; | |
| 112 | var i: usize = 0; | |
| 113 | while (i < line.len) : (i += 1) { | |
| 114 | const c = line[i]; | |
| 115 | if (in_string) { | |
| 116 | if (c == '\\') { | |
| 117 | i += 1; | |
| 118 | } else if (c == '"') { | |
| 119 | in_string = false; | |
| 120 | } | |
| 121 | } else if (c == '"') { | |
| 122 | in_string = true; | |
| 123 | } else if (c == '/' and i + 1 < line.len and line[i + 1] == '/') { | |
| 124 | return line[0..i]; | |
| 125 | } | |
| 126 | } | |
| 127 | return line; | |
| 128 | } | |
| 129 | ||
| 130 | test "the scanner recognises every silent shape the dispatcher used to have" { | |
| 131 | // Handed both answers before it is trusted with the real file: a scanner | |
| 132 | // that finds nothing proves nothing until it has been shown to find | |
| 133 | // something. | |
| 134 | const silent = [_][]const u8{ | |
| 135 | " const text = j.stringToUtf8(allocator, value) catch return jni.JNI_FALSE;", | |
| 136 | " const s = foo() catch return null;", | |
| 137 | " return j.newStringUtf8(allocator, json) catch null;", | |
| 138 | " events.settle(allocator, global, payload) catch {};", | |
| 139 | " const text = j.stringToUtf8(allocator, message) catch return;", | |
| 140 | " .latitude = json_number.fromDouble(j, allocator, latitude) catch return,", | |
| 141 | " const call = (dbCall(j, allocator, sql) catch return jni.JNI_FALSE) orelse", | |
| 142 | " const x = (render(allocator) catch return null) orelse", | |
| 143 | " j.staticMethodId(holder, \"x\", \"()V\") catch return jni.JNI_FALSE,", | |
| 144 | }; | |
| 145 | for (silent) |line| { | |
| 146 | if (firstSilentCatch(line) == null) { | |
| 147 | std.debug.print("\nthe scanner missed a silent decline:\n {s}\n", .{line}); | |
| 148 | return error.ScannerMissedSilentCatch; | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | const fine = [_][]const u8{ | |
| 153 | " const text = j.stringToUtf8(allocator, value) catch |err| return fellThrough(\"log\", err, jni.JNI_FALSE);", | |
| 154 | " events.settle(allocator, global, payload) catch |err| undelivered(\"reviewError\", err);", | |
| 155 | " const cls = j.findClass(holder_class) catch return .class_not_found;", | |
| 156 | " const plan = files.planFor(data_text) catch {", | |
| 157 | " // `catch return null` used to be everywhere", | |
| 158 | " const url = \"https://example.com\"; // catch {} in a comment", | |
| 159 | " const nullable = returnValue() catch |e| nullOr(e);", | |
| 160 | }; | |
| 161 | for (fine) |line| { | |
| 162 | if (firstSilentCatch(line)) |finding| { | |
| 163 | std.debug.print("\nthe scanner flagged a handled catch:\n {s}\n", .{finding.text}); | |
| 164 | return error.ScannerFlaggedHandledCatch; | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | test "no native in the Android dispatcher declines without saying so" { | |
| 170 | if (firstSilentCatch(dispatch)) |finding| { | |
| 171 | std.debug.print( | |
| 172 | \\ | |
| 173 | \\src/android_dispatch.zig:{d} declines without saying so: | |
| 174 | \\ | |
| 175 | \\ {s} | |
| 176 | \\ | |
| 177 | \\A native that gives up here hands the action back to the Kotlin shim | |
| 178 | \\(or, from a callback, drops it) with nothing in logcat — the page still | |
| 179 | \\gets an answer, so the failure is invisible. Capture the error and route | |
| 180 | \\it through `fellThrough`, `failedWithoutFallback` or `undelivered`. | |
| 181 | \\ | |
| 182 | , .{ finding.line, finding.text }); | |
| 183 | return error.SilentDecline; | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | test "the three decline messages are the ones the E2E suite looks for" { | |
| 188 | // scripts/mobile-e2e/android.ts fails its runtime leg on these phrases. If | |
| 189 | // a helper's wording drifted, the suite would stop seeing declines and pass | |
| 190 | // on a runtime that was handing every call to Kotlin. | |
| 191 | for ([_][]const u8{ | |
| 192 | " fell through to the shim (", | |
| 193 | " failed with no fallback (", | |
| 194 | " could not reach the page (", | |
| 195 | }) |phrase| { | |
| 196 | if (std.mem.indexOf(u8, dispatch, phrase) == null) { | |
| 197 | std.debug.print("\nandroid_dispatch.zig no longer contains \"{s}\"\n", .{phrase}); | |
| 198 | return error.DeclinePhraseMissing; | |
| 199 | } | |
| 200 | } | |
| 201 | } | |