ConnectivityManager.NetworkCallback is an abstract Java class, and JNI cannot subclass one — RegisterNatives binds methods onto a class that already exists in the APK, so a native can be called from Java but cannot be a Java object of a type the framework demands.
Same split as the reply channel and the main-thread trampoline: the object lives in Kotlin, the work lives in Zig. CraftNative holds the callback and the registration; every override forwards to nativeNetworkChanged, which reads the status and sends the event.
What actually moves
Zig already served getNetworkStatus, and sendNetworkChange is getNetworkStatus() plus a delivery — so the event payload and the polled answer are now literally the same function rather than two implementations that have to agree.
The callback fires on a Binder thread, which needs nothing new: events.settle attaches whatever thread it is called on, and the Kotlin deliverer hops to the main looper. That is the reply channel earning its shape a second time.
The leak is reproduced on purpose (#182)
A second startNetworkMonitoring registers a second callback and overwrites the reference to the first. The orphan keeps waking the app on every network change, can never be unregistered, and makes every change fire twice — and stopNetworkMonitoring only ever unregisters the most recent one.
CraftNative.startNetworkWatch does exactly the same. Guarding on the Zig path alone would mean a page calling start twice gets one event per change through Zig and two through Kotlin — a divergence visible only as a differing event count, which is worse than the bug being consistent. The comment at the seam says so, and #182 has the suggested fix.
This is the same shape as #179 (watchPosition orphaning its predecessor), which suggests the pattern rather than the instance is what wants fixing.
And stop cannot decline
A false from stop after a successful start would leave the callback registered: the shim's own networkCallback field is null, so its stop finds nothing to unregister. The holder's stop is the only thing that can undo the holder's start, so the native reports the JNI failure rather than pretending the shim can finish the job.
Not a general mechanism
Worth saying plainly, since I said the opposite was true of runOnMain: this does not generalise. Each Java interface needs its own Kotlin subclass, so this shim buys the network pair and nothing else. SensorEventListener, RecognitionListener, LifecycleEventObserver, ScanCallback and the rest each need their own — the pattern is now established, but the cost is per-family.
Testing
zig build test:android passes with the ratchet at 59. The mutation fired: pointing the event at a promise-shaped global instead of _craftNetworkChangeCallback fails a named test. Both libcraft.so targets still build with no NDK.