module UI::CallbackRegistry

Overview

Module-level registry that prevents Crystal Proc closures from being garbage collected while native code holds references to them.

Problem

When Crystal passes a Proc as a C function pointer (e.g., to the ObjC target-action mechanism or JNI callback bridge), BoehmGC may collect the closure if no Crystal-side reference remains. This causes a use-after-free crash when the native side later invokes the callback.

Solution

CallbackRegistry holds a strong reference to every registered Proc in a module-level Hash. Each registration returns a unique UInt64 ID that the native side stores. When the native callback fires, it passes the ID back to Crystal, which looks up and invokes the live Proc.

Lifecycle

  1. Register a callback: id = CallbackRegistry.register(proc)
  2. Pass id to the native side (stored in SEL name, JNI callback ID, etc.)
  3. Native fires: calls crystal_ui_callback_dispatch(id) -> CallbackRegistry.call(id)
  4. On teardown: CallbackRegistry.unregister(id) removes the strong reference

Thread Safety

The registry itself is not thread-safe. All registration, unregistration, and callback dispatch should happen on the main thread, which matches the normal UI callback model for AppKit/UIKit/Android.

Defined in:

ui/native/callback_registry.cr

Class Method Summary

Class Method Detail

def self.call(id : UInt64) : Nil #

Invoke the callback registered under the given ID.

If the ID is not found (e.g., it was already unregistered), this is a safe no-op. This prevents crashes if a native callback fires after the Crystal side has torn down.


[View source]
def self.call_bool(id : UInt64, value : Bool) : Nil #

Invoke the Bool callback registered under the given ID.


[View source]
def self.call_float(id : UInt64, value : Float64) : Nil #

Invoke the Float64 callback registered under the given ID.


[View source]
def self.call_int(id : UInt64, value : Int32) : Nil #

Invoke the Int32 callback registered under the given ID.


[View source]
def self.call_string(id : UInt64, value : String) : Nil #

Invoke the String callback registered under the given ID.


[View source]
def self.call_string_bool(id : UInt64, value : String) : Bool #

Invoke the String -> Bool callback registered under the given ID.

Missing IDs default to true so native policy delegates stay permissive instead of failing closed when a view has already been torn down.


[View source]
def self.call_time(id : UInt64, value : Time) : Nil #

Invoke the Time callback registered under the given ID.


[View source]
def self.clear : Nil #

Remove all registered callbacks from all typed hashes.

Intended for use in test cleanup (Spec.after_each). Do NOT call this in production code -- use .unregister for targeted cleanup.


[View source]
def self.invoke_swiftkit(token : UInt64, value : Float64) : Nil #

Dispatch a SwiftKit action by token. Called from the ap_swiftkit_invoke_action C trampoline.

Looks the token up across the relevant typed registries:

  1. The no-arg Proc(Nil) registry (Button taps fire here).
  2. The Float64 registry (Slider/Stepper/Toggle fire here).

Unknown tokens fall through silently — mirrors the existing crystal_ui_*_callback_dispatch convention so a stale callback fired by Swift after Crystal teardown does not crash.


[View source]
def self.invoke_swiftkit_string(token : UInt64, value : String) : Nil #

Phase 6.10 Rem 4 (Item 1) — string-valued counterpart to .invoke_swiftkit. Routes Proc(String, Nil) callbacks fired by AssetPipelineSwiftKit's string trampoline. Unknown tokens fall through silently — mirrors the float channel's policy.


[View source]
def self.register(callback : Proc(Nil)) : UInt64 #

Register a callback proc and return its unique ID.

The proc is held by strong reference until .unregister is called.


[View source]
def self.register(&block : -> Nil) : UInt64 #

Register a callback block and return its unique ID.

Convenience overload that wraps a block in a Proc.


[View source]
def self.register_action(&block : -> Nil) : UInt64 #

Register a no-arg SwiftKit action (Button#on_tap, MenuItem activation). Returns the opaque UInt64 token the Swift facade keeps.


[View source]
def self.register_action_with_value(&block : Float64 -> Nil) : UInt64 #

Register a Float64-valued SwiftKit action (Slider#on_change, Stepper#on_change, Toggle#on_change after Bool→Float64 coercion).


[View source]
def self.register_bool(callback : Proc(Bool, Nil)) : UInt64 #

Register a Bool callback proc and return its unique ID.


[View source]
def self.register_float(callback : Proc(Float64, Nil)) : UInt64 #

Register a Float64 callback proc and return its unique ID.


[View source]
def self.register_int(callback : Proc(Int32, Nil)) : UInt64 #

Register an Int32 callback proc and return its unique ID.


[View source]
def self.register_string(callback : Proc(String, Nil)) : UInt64 #

Register a String callback proc and return its unique ID.


[View source]
def self.register_string_bool(callback : Proc(String, Bool)) : UInt64 #

Register a String -> Bool callback proc and return its unique ID.


[View source]
def self.register_time(callback : Proc(Time, Nil)) : UInt64 #

Register a Time callback proc and return its unique ID.


[View source]
def self.size : Int32 #

Returns the number of currently registered callbacks across all typed hashes.


[View source]
def self.unregister(id : UInt64) : Nil #

Remove the callback registered under the given ID.

After this call, the Proc is eligible for GC and the ID will no longer resolve. Safe to call with an ID that was already unregistered. Checks all typed hashes.


[View source]
def self.unregister(ids : Array(UInt64)) : Nil #

Remove multiple callbacks by their IDs.

Convenience method for bulk cleanup during NativeView#teardown!.


[View source]