Skip to content

[macOS] Replace custom main loop with [NSApp run] and CFRunLoop observer.#104397

Merged
Repiteo merged 1 commit intogodotengine:masterfrom
bruvzg:mac_main_loop
Mar 25, 2025
Merged

[macOS] Replace custom main loop with [NSApp run] and CFRunLoop observer.#104397
Repiteo merged 1 commit intogodotengine:masterfrom
bruvzg:mac_main_loop

Conversation

@bruvzg
Copy link
Copy Markdown
Member

@bruvzg bruvzg commented Mar 20, 2025

Replaces custom main loop with standard [NSApp run] loop, and CFRunLoop observer hook. Should prevent main loop from getting block while using native menu/native popups.

Might fix window activation issues when running from console, and required to properly support native macOS menus in #76829.

Copy link
Copy Markdown
Contributor

@stuartcarnie stuartcarnie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how much of this is still experimentation, but I added a few comments to suggest some alternatives that utilise AppKit's native application lifecycle events.

Could ask what problem this is trying to solve, as perhaps I can help test?

@bruvzg
Copy link
Copy Markdown
Member Author

bruvzg commented Mar 20, 2025

Not sure how much of this is still experimentation

Just not sufficiently tested. But so far I have not encountered any issues with it.

The specific issue it's fixing, is the main loop blocking when you open native menu, and this part seems to be working fine.

@bruvzg bruvzg force-pushed the mac_main_loop branch 3 times, most recently from 2f2ae02 to eb50822 Compare March 21, 2025 06:10
@bruvzg bruvzg marked this pull request as ready for review March 21, 2025 06:32
@bruvzg bruvzg requested a review from a team as a code owner March 21, 2025 06:32
@akien-mga akien-mga requested a review from stuartcarnie March 21, 2025 08:34
Copy link
Copy Markdown
Contributor

@stuartcarnie stuartcarnie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I run this with the following arguments:

--screen 1 --editor --rendering-driver metal

The application thinks it is trying to open a file named metal, so it calls:

- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames {

which launches a new instance of Godot, and that too passes the args, so it launches another, ad nauseam:

CleanShot 2025-03-22 at 06 33 57@2x

@bruvzg
Copy link
Copy Markdown
Member Author

bruvzg commented Mar 21, 2025

which launches a new instance of Godot, and that too passes the args, so it launches another, ad nauseam

Moved init for applicationWillFinishLaunching to applicationDidFinishLaunching, seems to be working correctly now.

@bruvzg

This comment was marked as resolved.

@stuartcarnie
Copy link
Copy Markdown
Contributor

I notice with this change, when running with the --editor flag, the main Godot window takes longer to appear and show the Godot logo than previously. I'll see if it is taking longer to launch overall, or if that is just a difference in the app lifecycle.

@bruvzg
Copy link
Copy Markdown
Member Author

bruvzg commented Mar 21, 2025

I'll see if it is taking longer to launch overall, or if that is just a difference in the app lifecycle.

I guess some places in the editor that are blocking main thread still need manual event processing.

@stuartcarnie
Copy link
Copy Markdown
Contributor

I guess some places in the editor that are blocking main thread still need manual event processing.

Yes, removing the AppKit message pump from process_events is the cause, if I add this back in:

void DisplayServerMacOS::process_events() {
	ERR_FAIL_COND(!Thread::is_main_thread());

	while (true) {
		NSEvent *event = [NSApp
				nextEventMatchingMask:NSEventMaskAny
							untilDate:[NSDate distantPast]
							   inMode:NSDefaultRunLoopMode
							  dequeue:YES];

		if (event == nil) {
			break;
		}

		[NSApp sendEvent:event];
	}

then the window pops up immediately again.

@stuartcarnie
Copy link
Copy Markdown
Contributor

stuartcarnie commented Mar 21, 2025

Removing that logic from process_events means there is no way for a Godot client application or the Godot editor to ensure the AppKit message pump is processed periodically on the main thread, which is different from other DisplayServer implementations. Other frameworks / toolkits, like SDL, glfw, etc have a "poll events" function.

I can see Windows polls the message pump:

while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}

and so does Linux, (at least for Wayland) as it processes events on a separate thread to ensure they are continuously consumed.

I think that would be a fairly significant and breaking change, but adding that back should be fine.

@bruvzg
Copy link
Copy Markdown
Member Author

bruvzg commented Mar 21, 2025

Yes, I have re-added it back, it's dequeuing events so should not interfere with the same processing done by the NSApp run.

@stuartcarnie
Copy link
Copy Markdown
Contributor

stuartcarnie commented Mar 21, 2025

Yes, I have re-added it back, it's dequeuing events so should not interfere with the same processing done by the NSApp run.

Great! I'll do a bit more testing, but I think that should resolve any concerns I had.

I did find a few other places where this would caused a problem, as it wouldn't have been pumping events, like this confirmation dialog loop that doesn't break:

godot/editor/editor_node.cpp

Lines 5635 to 5641 in efc3e3a

while (true) {
DisplayServer::get_singleton()->process_events();
Main::iteration();
if (singleton->immediate_dialog_confirmed || !cd->is_visible()) {
break;
}
}

Copy link
Copy Markdown
Contributor

@stuartcarnie stuartcarnie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't observed any issues, and the changes look good. I've created a patch with some recommended improvements:

  • main_loop_initalized is equivalent to main_loop != nullptr, so I removed it
  • only start the pre_wait_observer if main_loop is initialised, so we don't need to check if main_loop is initialised via the main_loop_initialized in the pre_wait_observer_cb function
  • If Main::iterate in the pre_wait_observer_cb, we would set should_terminate to true and then outside the if, remove the observer. I created a terminate function that ensures the callback is removed, so we don't need to check should_terminate every call to the pre_wait_observer_cb function, which was redundant

The end result is fewer if branches that make the pre_wait_observer_cb callback code a little easier to follow.

Subject: [PATCH] Runloop improvements
---
Index: platform/macos/os_macos.mm
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/platform/macos/os_macos.mm b/platform/macos/os_macos.mm
--- a/platform/macos/os_macos.mm	(revision d4b0e608811dde1eff6f3ae57aa3625dd6091fd2)
+++ b/platform/macos/os_macos.mm	(date 1742684604000)
@@ -49,31 +49,20 @@
 void OS_MacOS::pre_wait_observer_cb(CFRunLoopObserverRef p_observer, CFRunLoopActivity p_activiy, void *p_context) {
 	OS_MacOS *os = static_cast<OS_MacOS *>(OS::get_singleton());
 
-	if (os->main_loop_initalized && !os->should_terminate) {
-		@autoreleasepool {
-			@try {
-				if (DisplayServer::get_singleton()) {
-					static_cast<DisplayServerMacOS *>(DisplayServer::get_singleton())->process_events(); // Get rid of pending events.
-				}
-				os->joypad_apple->process_joypads();
+	@autoreleasepool {
+		@try {
+			if (DisplayServer::get_singleton()) {
+				DisplayServer::get_singleton()->process_events(); // Get rid of pending events.
+			}
+			os->joypad_apple->process_joypads();
 
-				if (Main::iteration()) {
-					os->should_terminate = true;
-				}
-			} @catch (NSException *exception) {
-				ERR_PRINT("NSException: " + String::utf8([exception reason].UTF8String));
-			}
+			if (Main::iteration()) {
+				os->terminate();
+			}
+		} @catch (NSException *exception) {
+			ERR_PRINT("NSException: " + String::utf8([exception reason].UTF8String));
 		}
 	}
-
-	if (os->should_terminate) {
-		CFRunLoopRemoveObserver(CFRunLoopGetCurrent(), static_cast<OS_MacOS *>(OS::get_singleton())->pre_wait_observer, kCFRunLoopCommonModes);
-		CFRelease(os->pre_wait_observer);
-
-		// Note: use "stop" instead of "terminate" to ensure control is returned to "OS_MacOS::run".
-		[NSApp stop:nil];
-		[NSApp abortModal];
-	}
 
 	CFRunLoopWakeUp(CFRunLoopGetCurrent()); // Prevent main loop from sleeping.
 }
@@ -839,13 +828,15 @@
 	return PREFERRED_TEXTURE_FORMAT_S3TC_BPTC;
 }
 
-bool OS_MacOS::run() {
+void OS_MacOS::run() {
 	[NSApp run];
 
-	if (main_loop && main_loop_initalized) {
+	if (main_loop) {
 		main_loop->finalize();
+		@autoreleasepool {
+			Main::cleanup();
+		}
 	}
-	return main_loop_initalized;
 }
 
 void OS_MacOS::start_main() {
@@ -864,32 +855,38 @@
 				@autoreleasepool {
 					main_loop->initialize();
 				}
-				main_loop_initalized = true;
-			} else {
-				should_terminate = true;
+				pre_wait_observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, &pre_wait_observer_cb, nullptr);
+				CFRunLoopAddObserver(CFRunLoopGetCurrent(), pre_wait_observer, kCFRunLoopCommonModes);
+				return;
 			}
 		} else {
 			set_exit_code(EXIT_FAILURE);
-			should_terminate = true;
 		}
-	} else {
-		if (err == ERR_HELP) { // Returned by --help and --version, so success.
-			set_exit_code(EXIT_SUCCESS);
-		} else {
-			set_exit_code(EXIT_FAILURE);
-		}
-		should_terminate = true;
-	}
-
-	pre_wait_observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, &pre_wait_observer_cb, nullptr);
-	CFRunLoopAddObserver(CFRunLoopGetCurrent(), pre_wait_observer, kCFRunLoopCommonModes);
+	} else if (err == ERR_HELP) { // Returned by --help and --version, so success.
+		set_exit_code(EXIT_SUCCESS);
+	} else {
+		set_exit_code(EXIT_FAILURE);
+	}
+
+	terminate();
+}
+
+void OS_MacOS::terminate() {
+	if (pre_wait_observer) {
+		CFRunLoopRemoveObserver(CFRunLoopGetCurrent(), pre_wait_observer, kCFRunLoopCommonModes);
+		CFRelease(pre_wait_observer);
+		pre_wait_observer = nil;
+	}
+
+	should_terminate = true;
+	[NSApp stop:nil];
+	[NSApp abortModal];
 }
 
 OS_MacOS::OS_MacOS(const char *p_execpath, int p_argc, char **p_argv) {
 	execpath = p_execpath;
 	argc = p_argc;
 	argv = p_argv;
-
 	if (is_sandboxed()) {
 		// Load security-scoped bookmarks, request access, remove stale or invalid bookmarks.
 		NSArray *bookmarks = [[NSUserDefaults standardUserDefaults] arrayForKey:@"sec_bookmarks"];
Index: platform/macos/godot_main_macos.mm
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/platform/macos/godot_main_macos.mm b/platform/macos/godot_main_macos.mm
--- a/platform/macos/godot_main_macos.mm	(revision d4b0e608811dde1eff6f3ae57aa3625dd6091fd2)
+++ b/platform/macos/godot_main_macos.mm	(date 1742684555000)
@@ -64,11 +64,7 @@
 	// We must override main when testing is enabled.
 	TEST_MAIN_OVERRIDE
 
-	if (os.run()) {
-		@autoreleasepool {
-			Main::cleanup();
-		}
-	}
+	os.run();
 
 	return os.get_exit_code();
 }
Index: platform/macos/os_macos.h
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/platform/macos/os_macos.h b/platform/macos/os_macos.h
--- a/platform/macos/os_macos.h	(revision d4b0e608811dde1eff6f3ae57aa3625dd6091fd2)
+++ b/platform/macos/os_macos.h	(date 1742684565000)
@@ -46,7 +46,6 @@
 
 	id delegate = nullptr;
 	bool should_terminate = false;
-	bool main_loop_initalized = false;
 
 	JoypadApple *joypad_apple = nullptr;
 
@@ -59,7 +58,7 @@
 
 	CrashHandler crash_handler;
 
-	CFRunLoopObserverRef pre_wait_observer;
+	CFRunLoopObserverRef pre_wait_observer = nil;
 
 	MainLoop *main_loop = nullptr;
 
@@ -72,6 +71,8 @@
 	static _FORCE_INLINE_ String get_framework_executable(const String &p_path);
 	static void pre_wait_observer_cb(CFRunLoopObserverRef p_observer, CFRunLoopActivity p_activiy, void *p_context);
 
+	void terminate();
+
 protected:
 	virtual void initialize_core() override;
 	virtual void initialize() override;
@@ -139,7 +140,7 @@
 	virtual String get_system_ca_certificates() override;
 	virtual OS::PreferredTextureFormat get_preferred_texture_format() const override;
 
-	bool run(); // Runs macOS native event loop.
+	void run(); // Runs macOS native event loop.
 	void start_main(); // Initializes and runs Godot main loop.
 	bool os_should_terminate() const { return should_terminate; }
 	int get_cmd_argc() const { return argc; }

@bruvzg bruvzg marked this pull request as draft March 23, 2025 14:50
@bruvzg
Copy link
Copy Markdown
Member Author

bruvzg commented Mar 23, 2025

Changed to the draft for now, since there seems to some issues with closing the windows (sometimes it only reacts to close button after a second click, and window stay open for excessively long time).

However, calling this method from a timer or run-loop observer routine wouldn’t stop the run loop because they don’t result in the posting of an NSEvent object.

I guess related to stop being called form a loop observer.

@stuartcarnie
Copy link
Copy Markdown
Contributor

I guess related to stop being called form a loop observer.

I'll test and experiment more – hadn't noticed and issues, but perhaps I was pressing ⌘+Q rather than hitting close button

@bruvzg bruvzg marked this pull request as ready for review March 24, 2025 08:32
@bruvzg
Copy link
Copy Markdown
Member Author

bruvzg commented Mar 24, 2025

Added following changes:

  • changed process_events to only pump native events if it's called directly, but not in a loop observer (that seems to be the reason for requiring double click on close button).
  • changed activation policy to avoid dock icon from showing when running in headless mode (app starts in accessory mode, and changes it the show_window).
  • changed it to use terminate and exit(exit_code) in the applicationWillTerminate to avoid long hangs on exit.

@stuartcarnie
Copy link
Copy Markdown
Contributor

Will give it a try in my morning and let you know

@stuartcarnie stuartcarnie self-requested a review March 24, 2025 22:11
Copy link
Copy Markdown
Contributor

@stuartcarnie stuartcarnie left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, @bruvzg – tried various ways to break it without issue. Nice work 👏🏻

@Repiteo Repiteo merged commit c3ecb72 into godotengine:master Mar 25, 2025
20 checks passed
@Repiteo
Copy link
Copy Markdown
Contributor

Repiteo commented Mar 25, 2025

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants