ReviewOS

also looking at this

craft-native/craft

fix(android): make a dropped script visible, instead of reporting it delivered

#238
Merged glennmichael123 wants to merge fix/android-deliver-reports-drop into main
6 files +131 -16
packages/android/src/index.test.tsmodified+5-1
Changes to packages/android/src/index.test.ts
@@ -981,7 +981,11 @@ describe('Craft Android builder', () => {
981981 expect(bridge.match(/webView\.evaluateJavascript\(/g)?.length).toBe(4)
982982 expect(bridge).toContain('private fun evaluateJavascriptUnlessClosed(script: String)')
983983 expect(bridge).toContain('evaluatePromiseJavascript(script: String) {\n evaluateJavascriptUnlessClosed(script)')
984 expect(nativeDelivery).toContain('runCatching { deliverer?.invoke(script) }')
984 // Still no throw back into Zig, and since #231 it answers whether a
985 // deliverer took the script rather than swallowing the drop.
986 expect(nativeDelivery).toContain('fun deliver(script: String): Boolean')
987 expect(nativeDelivery).toContain('val target = deliverer ?: return false')
988 expect(nativeDelivery).toContain('runCatching { target(script); true }.getOrDefault(false)')
985989 for (const cleanup of [
986990 'window.__craftRejectPendingPromises',
987991 'CraftNative.close(activity)',
packages/android/templates/CraftNative.kt.templatemodified+12-6
Changes to packages/android/templates/CraftNative.kt.template
@@ -212,14 +212,20 @@ object CraftNative {
212212 /**
213213 * Run `script` in the page. Called from Zig, on any thread.
214214 *
215 * Silent when no deliverer is installed — which is every moment before the
216 * WebView exists, and after it is torn down. A throw here would cross back
217 * into Zig as a pending exception on a thread that has nowhere to report
218 * it, and the caller already counts what it could not deliver.
215 * Returns whether a deliverer took it. False is every moment before the
216 * WebView exists and after it is torn down, and it used to be
217 * indistinguishable from a delivery: the script was dropped, Zig's caller
218 * reported success, and the page waited on a promise nothing would settle
219 * (#231). Zig counts a false, and says so once.
220 *
221 * Still no throw. A throw here would cross back into Zig as a pending
222 * exception on a thread with nowhere to report it; false says the same
223 * thing and the caller can act on it.
219224 */
220225 @JvmStatic
221 fun deliver(script: String) {
222 runCatching { deliverer?.invoke(script) }
226 fun deliver(script: String): Boolean {
227 val target = deliverer ?: return false
228 return runCatching { target(script); true }.getOrDefault(false)
223229 }
224230
225231 private external fun nativeGetDeviceInfo(activity: Activity): String?
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -451,6 +451,9 @@ pub fn build(b: *std.Build) void {
451451 android_conformance_tests.root_module.addAnonymousImport("src/android_dispatch.zig", .{
452452 .root_source_file = b.path("src/android_dispatch.zig"),
453453 });
454 android_conformance_tests.root_module.addAnonymousImport("src/android_events.zig", .{
455 .root_source_file = b.path("src/android_events.zig"),
456 });
454457 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_device.zig", .{
455458 .root_source_file = b.path("src/bridge_android_device.zig"),
456459 });
packages/zig/src/android_events.zigmodified+74-9
Changes to packages/zig/src/android_events.zig
@@ -62,12 +62,37 @@ pub fn clearVm() void {
6262/// being diagnosed.
6363var dropped: usize = 0;
6464
65/// Whether the first drop has been said out loud.
66///
67/// Counting alone left the whole thing invisible: nothing reads `dropped` on a
68/// device, and the runtime E2E leg scans the log. So the first drop of a
69/// process logs, in the same words the other three decline paths use, and the
70/// rest are counted (#231). One line cannot drown a diagnosis; silence hid a
71/// script nobody delivered behind one that arrived.
72var drop_announced = false;
73
6574pub fn droppedCount() usize {
6675 return dropped;
6776}
6877
6978pub fn resetDroppedForTest() void {
7079 dropped = 0;
80 drop_announced = false;
81}
82
83/// Count a script that never reached the page, and say so the first time.
84fn dropScript(reason: []const u8) void {
85 dropped += 1;
86 if (drop_announced) return;
87 drop_announced = true;
88 // `warn`, the level `fellThrough` uses, for the same reason: a drop before
89 // the WebView exists is ordinary, and one after it is not, and this side
90 // cannot tell which it is looking at. The E2E scan matches the phrase, not
91 // the level, and fails the runtime leg on either.
92 std.log.warn(
93 "craft: deliver could not reach the page ({s}); further drops are counted, not logged",
94 .{reason},
95 );
7196}
7297
7398/// Hand `script` to `CraftNative.deliver`, from any thread.
@@ -77,18 +102,18 @@ pub fn resetDroppedForTest() void {
77102/// or an error nobody reads. `droppedCount` is what a diagnosis reads instead.
78103pub fn evaluate(allocator: std.mem.Allocator, script: []const u8) void {
79104 const vm = java_vm orelse {
80 dropped += 1;
105 dropScript("NoVm");
81106 return;
82107 };
83108
84109 const attachment = jni.attachCurrentThread(vm) orelse {
85 dropped += 1;
110 dropScript("NotAttached");
86111 return;
87112 };
88113 defer attachment.release(vm);
89114
90 deliverThrough(allocator, attachment.env, script) catch {
91 dropped += 1;
115 deliverThrough(allocator, attachment.env, script) catch |err| {
116 dropScript(@errorName(err));
92117 };
93118}
94119
@@ -109,8 +134,12 @@ fn deliverThrough(allocator: std.mem.Allocator, env: jni.JNIEnv, script: []const
109134 // halves rather than one four-byte sequence.
110135 const text = try j.newStringUtf8(allocator, script);
111136
112 const deliver = try j.staticMethodId(cls, "deliver", "(Ljava/lang/String;)V");
113 try j.callStaticVoidMethodA(cls, deliver, &.{.{ .l = text }});
137 // `Z`, not `V`: the answer is whether a deliverer took the script. It
138 // returned void until #231, so a script handed over before the WebView
139 // existed was dropped by Kotlin and reported as delivered here.
140 const deliver = try j.staticMethodId(cls, "deliver", "(Ljava/lang/String;)Z");
141 const took = try j.callStaticBooleanMethodA(cls, deliver, &.{.{ .l = text }});
142 if (took == jni.JNI_FALSE) return error.NoDeliverer;
114143}
115144
116145/// `window.dispatchEvent(new CustomEvent('name', {detail: json}))`.
@@ -185,6 +214,7 @@ var fake_detach_calls: usize = 0;
185214var fake_already_attached = true;
186215var fake_attach_succeeds = true;
187216var fake_find_class_succeeds = true;
217var fake_deliverer_installed = true;
188218
189219fn eobj(tag: usize) jni.jobject {
190220 return @ptrCast(&fake_storage[tag]);
@@ -218,7 +248,9 @@ fn eNewStringUTF(_: jni.JNIEnv, text: [*:0]const u8) callconv(.c) jni.jstring {
218248 @memcpy(fake_delivered[0..fake_delivered_len], span[0..fake_delivered_len]);
219249 return eobj(2);
220250}
221fn eCallStaticVoidMethodA(_: jni.JNIEnv, _: jni.jclass, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) void {}
251fn eCallStaticBooleanMethodA(_: jni.JNIEnv, _: jni.jclass, _: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jni.jboolean {
252 return if (fake_deliverer_installed) jni.JNI_TRUE else jni.JNI_FALSE;
253}
222254fn eExceptionOccurred(_: jni.JNIEnv) callconv(.c) jni.jobject {
223255 return null;
224256}
@@ -234,7 +266,7 @@ fn fakeVm(invoke: *jni.JNIInvokeInterface) void {
234266 fake_env_table.FindClass = @ptrCast(&eFindClass);
235267 fake_env_table.GetStaticMethodID = @ptrCast(&eStaticMethodId);
236268 fake_env_table.NewStringUTF = @ptrCast(&eNewStringUTF);
237 fake_env_table.CallStaticVoidMethodA = @ptrCast(&eCallStaticVoidMethodA);
269 fake_env_table.CallStaticBooleanMethodA = @ptrCast(&eCallStaticBooleanMethodA);
238270 fake_env_table.ExceptionOccurred = @ptrCast(&eExceptionOccurred);
239271 fake_env_table.PushLocalFrame = @ptrCast(&ePush);
240272 fake_env_table.PopLocalFrame = @ptrCast(&ePop);
@@ -251,6 +283,7 @@ fn fakeVm(invoke: *jni.JNIInvokeInterface) void {
251283 fake_already_attached = true;
252284 fake_attach_succeeds = true;
253285 fake_find_class_succeeds = true;
286 fake_deliverer_installed = true;
254287 resetDroppedForTest();
255288}
256289
@@ -293,7 +326,7 @@ test "a detached thread is attached and then detached again" {
293326 try testing.expectEqual(@as(usize, 1), fake_detach_calls);
294327}
295328
296test "every way delivery can fail is counted, not logged and not crashed" {
329test "every way delivery can fail is counted, said once, and not crashed" {
297330 var invoke: jni.JNIInvokeInterface = undefined;
298331 fakeVm(&invoke);
299332 const ptr: *const jni.JNIInvokeInterface = &invoke;
@@ -322,6 +355,38 @@ test "every way delivery can fail is counted, not logged and not crashed" {
322355 resetDroppedForTest();
323356 evaluate(testing.allocator, "window.z = 3;");
324357 try testing.expectEqual(@as(usize, 1), droppedCount());
358
359 // #231: Kotlin has no deliverer, which is every moment before the WebView
360 // exists. `deliver` returned void, so this arrived here as a success and
361 // the script was gone — the page waiting on a promise nothing settles.
362 fake_find_class_succeeds = true;
363 fake_deliverer_installed = false;
364 resetDroppedForTest();
365 evaluate(testing.allocator, "window.z = 3;");
366 try testing.expectEqual(@as(usize, 1), droppedCount());
367}
368
369test "a run of drops is announced once and counted the rest of the way" {
370 // The count exists because a device that drops drops in bulk, and the
371 // announcement exists because nothing on a device reads the count. Both,
372 // or the drop is either invisible or drowns what it is being read for.
373 var invoke: jni.JNIInvokeInterface = undefined;
374 fakeVm(&invoke);
375 const ptr: *const jni.JNIInvokeInterface = &invoke;
376 setVm(&ptr);
377 defer clearVm();
378
379 fake_deliverer_installed = false;
380 for (0..5) |_| evaluate(testing.allocator, "window.z = 3;");
381
382 try testing.expectEqual(@as(usize, 5), droppedCount());
383 try testing.expect(drop_announced);
384
385 // And delivery resumes without the counter moving.
386 fake_deliverer_installed = true;
387 evaluate(testing.allocator, "window.ok = 1;");
388 try testing.expectEqual(@as(usize, 5), droppedCount());
389 try testing.expectEqualStrings("window.ok = 1;", delivered());
325390}
326391
327392test "an attached thread is detached even when delivery fails" {
packages/zig/src/jni_runtime.zigmodified+8-0
Changes to packages/zig/src/jni_runtime.zig
@@ -937,6 +937,14 @@ pub const Jni = struct {
937937 try self.check();
938938 }
939939
940 pub fn callStaticBooleanMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jboolean {
941 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jboolean =
942 @ptrCast(self.table().CallStaticBooleanMethodA orelse return JniError.NotFound);
943 const result = call(self.env, cls, id, args.ptr);
944 try self.check();
945 return result;
946 }
947
940948 pub fn callStaticIntMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jint {
941949 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jint =
942950 @ptrCast(self.table().CallStaticIntMethodA orelse return JniError.NotFound);
packages/zig/test/android_conformance_test.zigmodified+29-0
Changes to packages/zig/test/android_conformance_test.zig
@@ -38,6 +38,7 @@ const testing = std.testing;
3838const android_spec = @embedFile("CraftBridge.kt");
3939const native_holder = @embedFile("CraftNative.kt");
4040const dispatch_source = @embedFile("src/android_dispatch.zig");
41const events_source = @embedFile("src/android_events.zig");
4142
4243/// Every Zig module serving part of the Android bridge. A module migrating
4344/// actions adds itself here — and the ratchet is what forces that, because
@@ -756,6 +757,34 @@ test "every registered descriptor is the one its external fun declares" {
756757 }
757758}
758759
760test "the one Kotlin method Zig calls back is declared the way Zig calls it" {
761 // The descriptor tests above cover Zig's natives — the calls that go
762 // Kotlin to Zig. `deliver` goes the other way, and nothing checks it: a
763 // mismatch here is a NoSuchMethodError on the first reply, long after
764 // load, on whichever device thread a callback arrived on.
765 //
766 // It returns Z rather than V since #231, because a script handed over
767 // before the WebView exists was dropped by Kotlin and reported as
768 // delivered to Zig.
769 const call = std.mem.indexOf(u8, events_source, "\"deliver\", \"") orelse
770 return error.DeliverCallNotFound;
771 const descriptor_start = call + "\"deliver\", \"".len;
772 const descriptor_end = std.mem.indexOfScalarPos(u8, events_source, descriptor_start, '"') orelse
773 return error.DeliverCallNotFound;
774 const descriptor = events_source[descriptor_start..descriptor_end];
775
776 const declared = std.mem.indexOf(u8, native_holder, "fun deliver(script: String): Boolean") != null;
777 if (!std.mem.eql(u8, descriptor, "(Ljava/lang/String;)Z") or !declared) {
778 std.debug.print(
779 "android_events calls CraftNative.deliver as '{s}', and CraftNative.kt declares it\n" ++
780 " as {s}. They have to agree: a mismatch is a NoSuchMethodError on the first\n" ++
781 " reply, and a V return is a drop the caller cannot see (#231).\n",
782 .{ descriptor, if (declared) "(Ljava/lang/String;)Z" else "something else" },
783 );
784 return error.DeliverDescriptorDoesNotMatchKotlin;
785 }
786}
787
759788test "the holder's package is the one JNI_OnLoad looks for" {
760789 // The third string with no compiler behind it. `FindClass` takes a binary
761790 // name with slashes; the Kotlin declares a package with dots. They are