git » sdk » commit 2651646

Handle closure context lifetimes in swift

author Stephen Paul Weber
2026-03-04 14:59:22 UTC
committer Stephen Paul Weber
2026-03-04 19:28:26 UTC
parent ea88903bd04a124c8e60095f9b6d0dd4cbe52235

Handle closure context lifetimes in swift

They are always free'd whent the object the method was called againt is
GC'd. This is correct for all our current code but if we get some
advanced case in the future we'll need a new hint for that.

There is also a hint now to release one early if it is an event handler
and gets removed.

HaxeSwiftBridge.hx +43 -4
borogove/Client.hx +15 -0
borogove/EventEmitter.hx +3 -1

diff --git a/HaxeSwiftBridge.hx b/HaxeSwiftBridge.hx
index e09750a..46d5763 100644
--- a/HaxeSwiftBridge.hx
+++ b/HaxeSwiftBridge.hx
@@ -272,6 +272,15 @@ class HaxeSwiftBridge {
 		}
 	}
 
+	static function identToStr(expr: Expr) {
+		switch (expr.expr) {
+		case EConst(CIdent(s)):
+			return s;
+		default:
+			throw "ident expencted";
+		}
+	}
+
 	static function metaIsSwiftExpose(e: ExprDef) {
 		return switch (e) {
 			case ECall(call, args):
@@ -424,7 +433,6 @@ class HaxeSwiftBridge {
 					}
 					if (fargs.length > 0) ibuilder.add(", ");
 					ibuilder.add("ctx");
-					// TODO unretained vs retained
 					ibuilder.add(") in\n\t\t\t\tlet ");
 					ibuilder.add(arg.name);
 					ibuilder.add(" = Unmanaged<AnyObject>.fromOpaque(ctx!).takeUnretainedValue() as! ");
@@ -463,8 +471,27 @@ class HaxeSwiftBridge {
 				default:
 			}
 			ibuilder.add("\n\t\t)");
-			builder.add("return ");
+			builder.add("let __result = ");
 			builder.add(castToSwift(ibuilder.toString(), finalTret, false, true));
+			for (arg in targs) {
+				switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) {
+				case TFun(fargs, fret):
+					final contextLifetime = fld.meta.filter(meta -> meta.name == ":HaxeSwiftBridge.contextLifetime").map(meta -> meta.params.map(identToStr)).find(params -> params[0] == arg.name);
+					if (contextLifetime != null) {
+						builder.add("\n\t\t" + contextLifetime[1] +  ".__contextLifetime[__result] = __" + arg.name + "_ptr");
+					}
+					builder.add("\n\t\tcontextLifetime[o] = (contextLifetime[o] ?? []) + [__" + arg.name + "_ptr]");
+				default:
+				}
+			}
+			final lifetimeEnds = fld.meta.find(meta -> meta.name == ":HaxeSwiftBridge.contextLifetimeEnds");
+			if (lifetimeEnds != null) {
+				builder.add("\n\t\tSelf.__contextLifetime[" + identToStr(lifetimeEnds.params[0]) + "].map { ending in\n");
+				builder.add("\t\t\tUnmanaged<AnyObject>.fromOpaque(ending).release()\n");
+				builder.add("\t\t\tcontextLifetime[o] = contextLifetime[o]?.filter { $0 != ending }\n");
+				builder.add("\t\t}");
+			}
+			builder.add("\n\t\treturn __result");
 			for (arg in targs) {
 				switch TypeTools.followWithAbstracts(Context.resolveType(Context.toComplexType(arg.t), Context.currentPos()), false) {
 				case TInst(_.get().name => "Array", [TInst(_.get().name => "String", _)]):
@@ -529,8 +556,11 @@ class HaxeSwiftBridge {
 		builder.add(" {\n");
 		if (!cls.isInterface && superClass == null) {
 			// We don't want this to be public, but it needs to be for the protocol, hmm
-			builder.add("\tpublic let o: UnsafeMutableRawPointer\n\n");
-			builder.add("\tinternal init(_ ptr: UnsafeMutableRawPointer) {\n\t\to = ptr\n\t}\n\n");
+			builder.add("\tpublic let o: UnsafeMutableRawPointer\n");
+			if (!cls.meta.extract(":HaxeSwiftBridge.contextLifetime").empty()) {
+				builder.add("\tinternal static var __contextLifetime: [Int32: UnsafeMutableRawPointer] = [:]\n");
+			}
+			builder.add("\n\tinternal init(_ ptr: UnsafeMutableRawPointer) {\n\t\to = ptr\n\t\tc_borogove.borogove_set_finalizer(ptr, releaseContexts)\n\t}\n\n");
 		}
 
 		if (!cls.isInterface && superClass != null) {
@@ -806,6 +836,15 @@ class HaxeSwiftBridge {
 				return useString(UnsafePointer(mptr?.assumingMemoryBound(to: CChar.self)))
 			}
 
+			internal var contextLifetime: [UnsafeMutableRawPointer: [UnsafeMutableRawPointer]] = [:]
+
+			internal func releaseContexts(o: UnsafeMutableRawPointer?) {
+				if let o {
+					contextLifetime[o]?.map { Unmanaged<AnyObject>.fromOpaque($0).release() }
+					contextLifetime[o] = nil
+				}
+			}
+
 			// From https://github.com/swiftlang/swift/blob/dfc3933a05264c0c19f7cd43ea0dca351f53ed48/stdlib/private/SwiftPrivate/SwiftPrivate.swift
 			public func scan<
 				S : Sequence, U
diff --git a/borogove/Client.hx b/borogove/Client.hx
index a19a29c..582a50f 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -1219,6 +1219,7 @@ class Client extends EventEmitter {
 		@param handler takes one argument, the Client that needs a password
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addPasswordNeededListener(handler:Client->Void) {
 		return this.on("auth/password-needed", (data) -> {
 			handler(this);
@@ -1232,6 +1233,7 @@ class Client extends EventEmitter {
 		@param handler takes no arguments
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addStatusOnlineListener(handler:()->Void) {
 		return this.on("status/online", (data) -> {
 			handler();
@@ -1245,6 +1247,7 @@ class Client extends EventEmitter {
 		@param handler takes no arguments
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addStatusOfflineListener(handler:()->Void) {
 		return this.on("status/offline", (data) -> {
 			handler();
@@ -1258,6 +1261,7 @@ class Client extends EventEmitter {
 		@param handler takes no arguments
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addConnectionFailedListener(handler:()->Void) {
 		return stream.on("status/error", (data) -> {
 			handler();
@@ -1271,6 +1275,7 @@ class Client extends EventEmitter {
 		@param handler takes two arguments, the PEM of the cert and an array of DNS names, and must return true to accept or false to reject
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addTlsCheckListener(handler:(String, Array<String>)->Bool) {
 		return stream.on("tls/check", (data) -> {
 			return EventValue(handler(data.pem, data.dnsNames));
@@ -1281,6 +1286,7 @@ class Client extends EventEmitter {
 	// TODO: haxe cpp erases enum into int, so using it as a callback arg is hard
 	// could just use int in C bindings, or need to come up with a good strategy
 	// for the wrapper
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addUserStateListener(handler: (String,String,Null<String>,UserState)->Void):EventHandlerToken {
 		return this.on("chat-state/update", (data) -> {
 			handler(data.message.senderId, data.message.chatId, data.message.threadId, data.userState);
@@ -1299,6 +1305,7 @@ class Client extends EventEmitter {
 	**/
 	#if cpp
 		// HaxeCBridge doesn't support "secondary" enums yet
+		@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 		public function addChatMessageListener(handler:(ChatMessage, Int)->Void) {
 	#else
 		public function addChatMessageListener(handler:(ChatMessage, ChatMessageEvent)->Void):EventHandlerToken {
@@ -1316,6 +1323,7 @@ class Client extends EventEmitter {
 		@param handler takes one argument, the ChatMessage
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addSyncMessageListener(handler:(ChatMessage)->Void):EventHandlerToken {
 		return this.on("message/sync", (data) -> {
 			handler(data);
@@ -1329,6 +1337,7 @@ class Client extends EventEmitter {
 		@param handler takes one argument, an array of Chats that were updated
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addChatsUpdatedListener(handler:Array<Chat>->Void) {
 		final updateChatBuffer: Map<String, Chat> = [];
 		var lastCall = -1.0;
@@ -1365,6 +1374,7 @@ class Client extends EventEmitter {
 		@param handler takes one argument, the call Session
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addCallRingListener(handler:(Session)->Void) {
 		return this.on("call/ring", (data) -> {
 			handler(data.session);
@@ -1378,6 +1388,7 @@ class Client extends EventEmitter {
 		@param handler takes two arguments, the associated Chat ID and Session ID
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addCallRetractListener(handler:(String,String)->Void) {
 		return this.on("call/retract", (data) -> {
 			handler(data.chatId, data.sid);
@@ -1391,6 +1402,7 @@ class Client extends EventEmitter {
 		@param handler takes one argument, the associated Session
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addCallRingingListener(handler:(Session)->Void) {
 		return this.on("call/ringing", (data) -> {
 			handler(data);
@@ -1404,6 +1416,7 @@ class Client extends EventEmitter {
 		@param handler takes one argument, the associated Session
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addCallUpdateStatusListener(handler:(InitiatedSession)->Void) {
 		return this.on("call/updateStatus", (data) -> {
 			handler(data.session);
@@ -1419,6 +1432,7 @@ class Client extends EventEmitter {
 		       and a boolean indicating if video is desired
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addCallMediaListener(handler:(InitiatedSession,Bool,Bool)->Void) {
 		return this.on("call/media", (data) -> {
 			handler(data.session, data.audio, data.video);
@@ -1433,6 +1447,7 @@ class Client extends EventEmitter {
 		       the new MediaStreamTrack, and an array of any associated MediaStreams
 		@returns token for use with removeEventListener
 	**/
+	@:HaxeSwiftBridge.contextLifetime(handler, EventEmitter)
 	public function addCallTrackListener(handler:(InitiatedSession,MediaStreamTrack,Array<MediaStream>)->Void) {
 		return this.on("call/track", (data) -> {
 			handler(data.session, data.track, data.streams);
diff --git a/borogove/EventEmitter.hx b/borogove/EventEmitter.hx
index da556a7..271a6ce 100644
--- a/borogove/EventEmitter.hx
+++ b/borogove/EventEmitter.hx
@@ -20,8 +20,9 @@ typedef EventHandlerToken = Int;
 @:build(HaxeCBridge.expose())
 @:build(HaxeSwiftBridge.expose())
 #end
+@:HaxeSwiftBridge.contextLifetime
 class EventEmitter {
-	private var nextEventHandlerToken = 0;
+	private static var nextEventHandlerToken = 0;
 	private var eventHandlers:Map<String,Map<EventHandlerToken, EventCallback>> = [];
 
 	private function new() { }
@@ -72,6 +73,7 @@ class EventEmitter {
 
 		@param token the token that was returned when the listener was added
 	**/
+	@:HaxeSwiftBridge.contextLifetimeEnds(token)
 	public function removeEventListener(token:EventHandlerToken) {
 		for (handlers in eventHandlers) {
 			handlers.remove(token);