ReviewOS

also looking at this

craft-native/craft

fix(android): make every Zig decline speak, and prove Zig answers

#205
Merged glennmichael123 wants to merge fix/android-zig-serves into main
8 files +616 -255
packages/zig/test/android_declines_test.zigadded+201-0
Changes to packages/zig/test/android_declines_test.zig
@@ -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
42const std = ;
43const testing = std.testing;
44
45const dispatch = @embedFile("android_dispatch.zig");
46
47const Finding = struct {
48 line: usize,
49 text: []const u8,
50};
51
52/// The first silent decline in `source`, or null if there is none.
53fn 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.
76fn 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
95fn 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
101fn 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.
110fn 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
130test "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
169test "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
187test "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}