ReviewOS

also looking at this

craft-native/craft

feat(android): getMemoryUsage and log — ratchet 102 -> 100

#143
Merged chris wants to merge feat/android-tier0-memory-log into main
7 files +377 -3
packages/android/templates/CraftBridge.kt.templatemodified+3-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -1583,6 +1583,7 @@ class CraftBridge(
15831583
15841584 @JavascriptInterface
15851585 fun log(message: String) {
1586 if (CraftNative.log(message)) return
15861587 android.util.Log.d("CraftBridge", message)
15871588 }
15881589
@@ -3845,6 +3846,8 @@ class CraftBridge(
38453846
38463847 @JavascriptInterface
38473848 fun getMemoryUsage(): String {
3849 CraftNative.getMemoryUsage()?.let { return it }
3850
38483851 val runtime = Runtime.getRuntime()
38493852 val usedMemory = runtime.totalMemory() - runtime.freeMemory()
38503853 val maxMemory = runtime.maxMemory()
packages/android/templates/CraftNative.kt.templatemodified+29-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -57,6 +57,8 @@ object CraftNative {
5757 }
5858
5959 private external fun nativeGetDeviceInfo(activity: Activity): String?
60 private external fun nativeGetMemoryUsage(): String?
61 private external fun nativeLog(message: String): Boolean
6062
6163 /**
6264 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -76,4 +78,31 @@ object CraftNative {
7678 null
7779 }
7880 }
81
82 /** The JVM heap as JSON, or null to use the Kotlin implementation. */
83 fun getMemoryUsage(): String? {
84 if (!isAvailable) return null
85 return try {
86 nativeGetMemoryUsage()
87 } catch (e: UnsatisfiedLinkError) {
88 null
89 }
90 }
91
92 /**
93 * Write a log line, reporting whether it was written.
94 *
95 * The only native here that answers with a boolean rather than a nullable
96 * result, because there is no value to return and "not mine" still has to
97 * be sayable — a void native would leave the caller unable to tell a
98 * written line from a declined one, and it would log twice or not at all.
99 */
100 fun log(message: String): Boolean {
101 if (!isAvailable) return false
102 return try {
103 nativeLog(message)
104 } catch (e: UnsatisfiedLinkError) {
105 false
106 }
107 }
79108}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -434,6 +434,9 @@ pub fn build(b: *std.Build) void {
434434 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_device.zig", .{
435435 .root_source_file = b.path("src/bridge_android_device.zig"),
436436 });
437 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_system.zig", .{
438 .root_source_file = b.path("src/bridge_android_system.zig"),
439 });
437440 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
438441
439442 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+59-1
Changes to packages/zig/src/android_dispatch.zig
@@ -37,6 +37,7 @@ const std = ;
3737const builtin = ;
3838const jni = ;
3939const device = ;
40const system = ;
4041
4142const Jni = jni.Jni;
4243
@@ -122,6 +123,20 @@ const natives = [_]jni.JNINativeMethod{
122123 .signature = "(Landroid/app/Activity;)Ljava/lang/String;",
123124 .fnPtr = @ptrCast(&nativeGetDeviceInfo),
124125 },
126 // No Activity parameter: `Runtime` and `Log` are static, so neither of
127 // these needs anything the app hands over. The signature says so, and a
128 // signature that asked for one would fail registration rather than be
129 // quietly ignored.
130 .{
131 .name = "nativeGetMemoryUsage",
132 .signature = "()Ljava/lang/String;",
133 .fnPtr = @ptrCast(&nativeGetMemoryUsage),
134 },
135 .{
136 .name = "nativeLog",
137 .signature = "(Ljava/lang/String;)Z",
138 .fnPtr = @ptrCast(&nativeLog),
139 },
125140};
126141
127142/// How binding went. A value rather than a log line, so every path is
@@ -184,6 +199,49 @@ fn nativeGetDeviceInfo(env: jni.JNIEnv, _: jni.jobject, activity: jni.jobject) c
184199 return j.newStringUtf(owned.ptr) catch null;
185200}
186201
202/// `nativeGetMemoryUsage()` — the JVM heap, as JSON.
203fn nativeGetMemoryUsage(env: jni.JNIEnv, _: jni.jobject) callconv(.c) jni.jstring {
204 const j = Jni.init(env);
205
206 var arena = std.heap.ArenaAllocator.init(backing);
207 defer arena.deinit();
208 const allocator = arena.allocator();
209
210 const usage = system.readMemory(j) catch |err| {
211 std.log.warn("craft: getMemoryUsage fell through to the shim ({s})", .{@errorName(err)});
212 return null;
213 };
214
215 const json = system.renderMemory(allocator, usage) catch return null;
216 const owned = allocator.allocSentinel(u8, json.len, 0) catch return null;
217 @memcpy(owned, json);
218
219 return j.newStringUtf(owned.ptr) catch null;
220}
221
222/// `nativeLog(message)` — returns whether Zig wrote the line.
223///
224/// A boolean rather than void, because "not mine" has to be expressible.
225/// `void` would leave the Kotlin unable to tell a successful native log from a
226/// runtime that declined, and it would log the message twice or not at all.
227fn nativeLog(env: jni.JNIEnv, _: jni.jobject, message: jni.jstring) callconv(.c) jni.jboolean {
228 const j = Jni.init(env);
229
230 var arena = std.heap.ArenaAllocator.init(backing);
231 defer arena.deinit();
232 const allocator = arena.allocator();
233
234 const text = j.stringToUtf8(allocator, message) catch return jni.JNI_FALSE;
235 const owned = allocator.allocSentinel(u8, text.len, 0) catch return jni.JNI_FALSE;
236 @memcpy(owned, text);
237
238 system.writeLog(j, owned.ptr) catch |err| {
239 std.log.warn("craft: log fell through to the shim ({s})", .{@errorName(err)});
240 return jni.JNI_FALSE;
241 };
242 return jni.JNI_TRUE;
243}
244
187245// =============================================================================
188246// Tests
189247// =============================================================================
@@ -295,7 +353,7 @@ test "the registered natives name methods the Kotlin actually declares" {
295353 // A descriptor is checked by the JVM at registration, so a wrong one fails
296354 // at load rather than at call — but only if the *name* matches something.
297355 // These two strings are the contract with CraftBridge.kt.
298 try testing.expectEqual(@as(usize, 1), natives.len);
356 try testing.expectEqual(@as(usize, 3), natives.len);
299357 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
300358
301359 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_system.zigadded+240-0
Changes to packages/zig/src/bridge_android_system.zig
@@ -0,0 +1,240 @@
1//! The Android actions that need nothing but the JVM: `getMemoryUsage` and
2//! `log`.
3//!
4//! Tier 0 in the sense the iOS migration used: no permission prompt, no UI, no
5//! callback, and — the part specific to Android — **no `Activity`**. Both reach
6//! a static class and stop, so neither depends on anything the app hands over.
7//!
8//! ## `getMemoryUsage` is not the iOS action wearing a different name
9//!
10//! iOS asks the kernel for this process's resident size through `task_info`.
11//! Android asks the *JVM* about its own heap through `java.lang.Runtime`, and
12//! those are different quantities: `Runtime.totalMemory()` is what the managed
13//! heap has reserved, not what the process holds. Every native allocation this
14//! bridge makes is outside it.
15//!
16//! So the two platforms answer with different keys and that is correct rather
17//! than sloppy — iOS emits `usedMB`/`residentSize`/`virtualSize`, Android emits
18//! `usedMB`/`maxMB`/`totalMB`/`usedBytes`/`maxBytes`. Only `usedMB` is shared,
19//! and even that means something different. Making them agree would mean one
20//! of them lying about which number it measured.
21//!
22//! ## The rounding is arithmetic, not formatting
23//!
24//! `Math.round(usedMemory / 1024.0 / 1024.0 * 100) / 100.0` is hundredths of a
25//! megabyte, rounded half up. Done here in integers for the reason
26//! `bridge_mobile_device.zig` gives for the same figure on iOS: the rounding
27//! becomes a property of a function that can be asserted on a host, rather than
28//! of a float format that has to be observed.
29
30const std = ;
31const jni = ;
32
33const Jni = jni.Jni;
34
35/// The action names, spelled exactly as `CraftBridge.kt` spells them. The
36/// conformance ratchet matches the two lists by string.
37pub const A = struct {
38 pub const get_memory_usage = "getMemoryUsage";
39 pub const log = "log";
40};
41
42/// What the JVM reports about its own heap.
43pub const MemoryUsage = struct {
44 used_bytes: i64,
45 max_bytes: i64,
46 total_bytes: i64,
47};
48
49/// Bytes as hundredths of a megabyte, rounded half up.
50///
51/// Integer arithmetic so the rounding is assertable. `Math.round` in Java is
52/// `floor(x + 0.5)`, which for a non-negative byte count is the same as adding
53/// half the divisor before dividing — and a byte count is never negative, so
54/// the difference between round-half-up and round-half-away never arises.
55///
56/// The divisor is 1024*1024/100 scaled: hundredths of a MiB means
57/// `bytes * 100 / (1024*1024)`, and the `+ half` is the round.
58pub fn hundredthsOfMebibyte(bytes: i64) i64 {
59 if (bytes <= 0) return 0;
60 const mib: i64 = 1024 * 1024;
61 return @divTrunc(bytes * 100 + @divTrunc(mib, 2), mib);
62}
63
64/// `usedMB` and friends, rendered the way `JSONObject.put(double)` renders
65/// them: a whole number loses its fraction, everything else keeps exactly the
66/// hundredths it has.
67///
68/// Java prints `117.0` for a whole double and Zig's `{d}` prints `117`. That
69/// difference is invisible to `JSON.parse` — both produce the same number — and
70/// chasing it would mean formatting by hand to match a Java quirk no consumer
71/// can observe.
72fn appendMegabytes(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), bytes: i64) !void {
73 const hundredths = hundredthsOfMebibyte(bytes);
74 const fraction = @mod(hundredths, 100);
75
76 try out.print(allocator, "{d}.", .{@divTrunc(hundredths, 100)});
77 // Padded by hand rather than with a width spec. `{d:0>2}` renders `+74`
78 // on this toolchain — a sign, not a zero — which is not a number JSON
79 // accepts, and the reply stops parsing at the dot.
80 if (fraction < 10) try out.append(allocator, '0');
81 try out.print(allocator, "{d}", .{fraction});
82}
83
84/// The reply bytes for `getMemoryUsage`.
85///
86/// Key order follows the Kotlin's `put` order, for the same reason the device
87/// module follows it: a diff against the Kotlin is how this gets reviewed.
88pub fn renderMemory(allocator: std.mem.Allocator, usage: MemoryUsage) ![]u8 {
89 var out: std.ArrayListUnmanaged(u8) = .empty;
90 errdefer out.deinit(allocator);
91
92 try out.appendSlice(allocator, "{\"usedMB\":");
93 try appendMegabytes(allocator, &out, usage.used_bytes);
94 try out.appendSlice(allocator, ",\"maxMB\":");
95 try appendMegabytes(allocator, &out, usage.max_bytes);
96 try out.appendSlice(allocator, ",\"totalMB\":");
97 try appendMegabytes(allocator, &out, usage.total_bytes);
98
99 // The byte counts are exact and stay integers. A `long` past 2^53 would
100 // lose precision in `JSON.parse`, but a JVM heap that large does not exist.
101 try out.appendSlice(allocator, ",\"usedBytes\":");
102 try out.print(allocator, "{d}", .{usage.used_bytes});
103 try out.appendSlice(allocator, ",\"maxBytes\":");
104 try out.print(allocator, "{d}", .{usage.max_bytes});
105 try out.append(allocator, '}');
106
107 return out.toOwnedSlice(allocator);
108}
109
110/// Ask `java.lang.Runtime` about the managed heap.
111///
112/// `used = total - free` is the Kotlin's own arithmetic, and it is the only
113/// one of the three that is derived rather than read.
114pub fn readMemory(j: Jni) !MemoryUsage {
115 try j.pushLocalFrame(8);
116 defer _ = j.popLocalFrame(null);
117
118 const cls = try j.findClass("java/lang/Runtime");
119 const runtime = try j.callStaticObjectMethodA(
120 cls,
121 try j.staticMethodId(cls, "getRuntime", "()Ljava/lang/Runtime;"),
122 &.{},
123 );
124
125 const total = try j.callLongMethod(runtime, try j.methodId(cls, "totalMemory", "()J"));
126 const free = try j.callLongMethod(runtime, try j.methodId(cls, "freeMemory", "()J"));
127 const max = try j.callLongMethod(runtime, try j.methodId(cls, "maxMemory", "()J"));
128
129 return .{ .used_bytes = total - free, .max_bytes = max, .total_bytes = total };
130}
131
132/// `android.util.Log.d("CraftBridge", message)`.
133///
134/// The tag is the Kotlin's, character for character. Anyone filtering logcat on
135/// `CraftBridge` is filtering on a string, and changing it because Zig now
136/// emits the line would break a filter that has nothing to do with this
137/// migration.
138pub fn writeLog(j: Jni, message: [*:0]const u8) !void {
139 try j.pushLocalFrame(8);
140 defer _ = j.popLocalFrame(null);
141
142 const cls = try j.findClass("android/util/Log");
143 const tag = try j.newStringUtf("CraftBridge");
144 const text = try j.newStringUtf(message);
145
146 // `Log.d` returns the number of bytes written, which the Kotlin ignores
147 // and so does this. Discarding it is not the same as calling a void
148 // method: the JVM would push a different frame for `CallStaticVoidMethodA`.
149 _ = try j.callStaticIntMethodA(
150 cls,
151 try j.staticMethodId(cls, "d", "(Ljava/lang/String;Ljava/lang/String;)I"),
152 &.{ .{ .l = tag }, .{ .l = text } },
153 );
154}
155
156// =============================================================================
157// Tests
158// =============================================================================
159
160const testing = std.testing;
161
162test "hundredths of a mebibyte round the way Math.round does" {
163 // Exact boundaries first: one MiB is 100 hundredths by definition.
164 try testing.expectEqual(@as(i64, 100), hundredthsOfMebibyte(1024 * 1024));
165 try testing.expectEqual(@as(i64, 200), hundredthsOfMebibyte(2 * 1024 * 1024));
166 try testing.expectEqual(@as(i64, 0), hundredthsOfMebibyte(0));
167
168 // Half rounds up, which is what `floor(x + 0.5)` does for a positive x.
169 // Half a hundredth of a MiB is 1048576/200 = 5242.88 bytes, so 5243 is the
170 // first byte count at or above the halfway point and 5242 is below it.
171 // (Writing the boundary as `1024*1024/200` truncates to 5242 and asserts
172 // the opposite of what it looks like — which is what the first version of
173 // this test did.)
174 try testing.expectEqual(@as(i64, 1), hundredthsOfMebibyte(5243));
175 try testing.expectEqual(@as(i64, 0), hundredthsOfMebibyte(5242));
176
177 // A negative cannot come from a byte count, and answering 0 is better than
178 // answering a negative megabyte.
179 try testing.expectEqual(@as(i64, 0), hundredthsOfMebibyte(-1));
180}
181
182test "the memory reply carries every key the Kotlin puts, and parses" {
183 const usage = MemoryUsage{
184 .used_bytes = 123_456_789,
185 .max_bytes = 536_870_912,
186 .total_bytes = 268_435_456,
187 };
188 const json = try renderMemory(testing.allocator, usage);
189 defer testing.allocator.free(json);
190
191 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
192 defer parsed.deinit();
193 const obj = parsed.value.object;
194
195 try testing.expectEqual(@as(usize, 5), obj.count());
196 try testing.expectEqual(@as(i64, 123_456_789), obj.get("usedBytes").?.integer);
197 try testing.expectEqual(@as(i64, 536_870_912), obj.get("maxBytes").?.integer);
198
199 // 123456789 / 1048576 = 117.7375… → 117.74
200 try testing.expectApproxEqAbs(@as(f64, 117.74), obj.get("usedMB").?.float, 0.0001);
201 // 536870912 is exactly 512 MiB, and 268435456 exactly 256.
202 try testing.expectApproxEqAbs(@as(f64, 512), obj.get("maxMB").?.float, 0.0001);
203 try testing.expectApproxEqAbs(@as(f64, 256), obj.get("totalMB").?.float, 0.0001);
204}
205
206test "a whole number of megabytes still renders as a number" {
207 // The `.00` path: `512.00` is valid JSON and parses to 512, but a bug in
208 // the fraction split would produce `512.` or `512.0` — one of which is not
209 // JSON at all.
210 const json = try renderMemory(testing.allocator, .{
211 .used_bytes = 1024 * 1024,
212 .max_bytes = 1024 * 1024,
213 .total_bytes = 1024 * 1024,
214 });
215 defer testing.allocator.free(json);
216
217 try testing.expect(std.mem.indexOf(u8, json, "\"usedMB\":1.00,") != null);
218
219 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{});
220 defer parsed.deinit();
221 try testing.expectApproxEqAbs(@as(f64, 1), parsed.value.object.get("usedMB").?.float, 0.0001);
222}
223
224test "a heap under a hundredth of a megabyte does not render as garbage" {
225 // The zero-fraction edge, from the other side: a two-digit pad is what
226 // stops 5 hundredths becoming "0.5".
227 const json = try renderMemory(testing.allocator, .{
228 .used_bytes = 52_429, // ~0.05 MiB
229 .max_bytes = 0,
230 .total_bytes = 0,
231 });
232 defer testing.allocator.free(json);
233 try testing.expect(std.mem.indexOf(u8, json, "\"usedMB\":0.05,") != null);
234 try testing.expect(std.mem.indexOf(u8, json, "\"maxMB\":0.00,") != null);
235}
236
237test "the action names match the Kotlin methods exactly" {
238 try testing.expectEqualStrings("getMemoryUsage", A.get_memory_usage);
239 try testing.expectEqualStrings("log", A.log);
240}
packages/zig/src/jni_runtime.zigmodified+39-0
Changes to packages/zig/src/jni_runtime.zig
@@ -710,6 +710,45 @@ pub const Jni = struct {
710710 return result;
711711 }
712712
713 pub fn callLongMethod(self: Self, obj: jobject, id: jmethodID) JniError!jlong {
714 const call: *const fn (JNIEnv, jobject, jmethodID) callconv(.c) jlong =
715 @ptrCast(self.table().CallLongMethod orelse return JniError.NotFound);
716 const result = call(self.env, obj, id);
717 try self.check();
718 return result;
719 }
720
721 // --- Static methods ---------------------------------------------------
722 //
723 // `Runtime.getRuntime()` and `Log.d(tag, msg)` are both static, and a
724 // static call is a different table entry from an instance one taking the
725 // class rather than the receiver. Passing a jclass to CallObjectMethod
726 // compiles and is undefined at runtime.
727
728 pub fn staticMethodId(self: Self, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) JniError!jmethodID {
729 const get: *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID =
730 @ptrCast(self.table().GetStaticMethodID orelse return JniError.NotFound);
731 const id = get(self.env, cls, name, sig);
732 try self.check();
733 return id orelse JniError.NotFound;
734 }
735
736 pub fn callStaticObjectMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jobject {
737 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jobject =
738 @ptrCast(self.table().CallStaticObjectMethodA orelse return JniError.NotFound);
739 const result = call(self.env, cls, id, args.ptr);
740 try self.check();
741 return result;
742 }
743
744 pub fn callStaticIntMethodA(self: Self, cls: jclass, id: jmethodID, args: []const jvalue) JniError!jint {
745 const call: *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jint =
746 @ptrCast(self.table().CallStaticIntMethodA orelse return JniError.NotFound);
747 const result = call(self.env, cls, id, args.ptr);
748 try self.check();
749 return result;
750 }
751
713752 pub fn objectClass(self: Self, obj: jobject) JniError!jclass {
714753 const get: *const fn (JNIEnv, jobject) callconv(.c) jclass =
715754 @ptrCast(self.table().GetObjectClass orelse return JniError.NotFound);
packages/zig/test/android_conformance_test.zigmodified+4-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -40,6 +40,7 @@ const dispatch_source = @embedFile("src/android_dispatch.zig");
4040/// migrated actions the scan cannot see read as "still owed".
4141const zig_sources = [_][]const u8{
4242 @embedFile("src/bridge_android_device.zig"),
43 @embedFile("src/bridge_android_system.zig"),
4344};
4445
4546/// How many spec actions Zig does not serve yet.
@@ -48,8 +49,9 @@ const zig_sources = [_][]const u8{
4849/// costs. If it ever needs raising, something has left Zig without leaving the
4950/// Kotlin, and that is the conversation this constant exists to force.
5051///
51/// History: 103 at the seam; 102 with getDeviceInfo.
52const max_not_yet_migrated: usize = 102;
52/// History: 103 at the seam; 102 with getDeviceInfo; 100 with getMemoryUsage
53/// and log — the first two that need no Activity at all.
54const max_not_yet_migrated: usize = 100;
5355
5456/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
5557///