From ace33e2f68413b12ef9cc76a1af963375b4f1094 Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 17 Oct 2026 15:51:46 -0700 Subject: [PATCH 1/2] Release the GIL while UIKit runs the application --- .gitignore | 5 ++ tests/threading/README.md | 13 ++++ tests/threading/pyproject.toml | 21 +++++++ .../threading/src/threadingprobe/__init__.py | 0 .../threading/src/threadingprobe/__main__.py | 1 + tests/threading/tests/__init__.py | 0 tests/threading/tests/threadingprobe.py | 59 +++++++++++++++++++ .../{{ cookiecutter.class_name }}/main.m | 12 +++- 8 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/threading/README.md create mode 100644 tests/threading/pyproject.toml create mode 100644 tests/threading/src/threadingprobe/__init__.py create mode 100644 tests/threading/src/threadingprobe/__main__.py create mode 100644 tests/threading/tests/__init__.py create mode 100644 tests/threading/tests/threadingprobe.py diff --git a/.gitignore b/.gitignore index 5b83825..b5e22d7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ .vscode .idea .rumdl_cache + +# Native launcher regression application +/tests/threading/build/ +/tests/threading/logs/ +/tests/threading/.briefcase/ diff --git a/tests/threading/README.md b/tests/threading/README.md new file mode 100644 index 0000000..c8eb5b6 --- /dev/null +++ b/tests/threading/README.md @@ -0,0 +1,13 @@ +# Native launcher threading regression + +This application returns from Python into `UIApplicationMain`, then starts a Python worker while UIKit is otherwise idle. The worker sleeps for 0.1 seconds and schedules a callback on the main thread. The test fails if the worker and callback cannot complete within one second. A three-second watchdog bounds a failure; there is no heartbeat before the verdict. + +With the old launcher, `UIApplicationMain` keeps the GIL and the worker cannot resume until the watchdog fires. With the corrected launcher, both the worker and its callback complete promptly. The test uses released Rubicon 0.5.7 and a lifecycle that does not nest a run loop, so it isolates the native launcher change. + +From this directory, with Briefcase installed and an iOS Simulator available: + +```console +briefcase run iOS --test --no-input -C 'template="../.."' +``` + +Use a fresh generated application after changing the template. Briefcase's `--update` option updates Python application code; it does not regenerate `main.m`. Use `-d ` to select a simulator. diff --git a/tests/threading/pyproject.toml b/tests/threading/pyproject.toml new file mode 100644 index 0000000..3f29801 --- /dev/null +++ b/tests/threading/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "threading-probe" +version = "0.0.1" +description = "Regression test for Python threads while UIKit is idle" +license = "BSD-3-Clause" +license-files = ["../../LICENSE"] +authors = [{name = "BeeWare"}] + +[tool.briefcase] +project_name = "threading-probe" +bundle = "org.beeware.tests" + +[tool.briefcase.app.threadingprobe] +formal_name = "Threading Probe" +description = "Regression test for the native launcher GIL" +sources = ["src/threadingprobe"] +test_sources = ["tests"] +requires = ["rubicon-objc==0.5.7"] + +[tool.briefcase.app.threadingprobe.iOS] +supported = true diff --git a/tests/threading/src/threadingprobe/__init__.py b/tests/threading/src/threadingprobe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/threading/src/threadingprobe/__main__.py b/tests/threading/src/threadingprobe/__main__.py new file mode 100644 index 0000000..d43cbfb --- /dev/null +++ b/tests/threading/src/threadingprobe/__main__.py @@ -0,0 +1 @@ +raise RuntimeError("Run this application with briefcase run iOS --test") diff --git a/tests/threading/tests/__init__.py b/tests/threading/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/threading/tests/threadingprobe.py b/tests/threading/tests/threadingprobe.py new file mode 100644 index 0000000..c67b95d --- /dev/null +++ b/tests/threading/tests/threadingprobe.py @@ -0,0 +1,59 @@ +"""Run in UIApplicationMain, without a nested run loop or heartbeat.""" + +import threading +import time + +from rubicon.objc import ObjCClass, objc_method +from rubicon.objc.eventloop import CFLifecycle, RubiconEventLoop + + +class NativeLifecycle(CFLifecycle): + def start(self): + # UIApplicationMain starts the outer run loop after this module returns. + pass + + +loop = RubiconEventLoop() +result = None + + +def report(): + print(f">>>>>>>>>> EXIT {result} <<<<<<<<<<", flush=True) + # Repeat only after the verdict, so logging startup cannot lose the result. + loop.call_later(1, report) + + +def finish(returncode): + global result + if result is None: + result = returncode + report() + + +def start_probe(): + started = time.monotonic() + + def delivered(): + elapsed = time.monotonic() - started + print(f"Worker and callback completed after {elapsed:.3f}s", flush=True) + finish(0 if elapsed < 1 else 1) + + def worker(): + time.sleep(0.1) + loop.call_soon_threadsafe(delivered) + + # There are no intervening main-thread timers to release the GIL by accident. + loop.call_later(3, finish, 1) + threading.Thread(target=worker, daemon=True).start() + + +class PythonAppDelegate(ObjCClass("UIResponder")): + @objc_method + def application_didFinishLaunchingWithOptions_( + self, application, launch_options + ) -> bool: + loop.call_later(0.1, start_probe) + return True + + +loop.run_forever_cooperatively(lifecycle=NativeLifecycle()) diff --git a/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m b/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m index 1acb2f7..2d60670 100644 --- a/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m +++ b/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m @@ -299,7 +299,17 @@ int main(int argc, char *argv[]) { // [1] http://beeware.org/rubicon // [2] http://pyobjus.readthedocs.org/ // [3] https://pythonhosted.org/pyobjc/ - UIApplicationMain(argc, argv, nil, @"PythonAppDelegate"); + // UIKit owns the native event loop. Detach Python's thread + // state so background threads and native callbacks can acquire + // the GIL while the application is idle. Restore it before + // exception handling, garbage collection, or finalization. + PyThreadState *thread_state = PyEval_SaveThread(); + @try { + UIApplicationMain(argc, argv, nil, @"PythonAppDelegate"); + } + @finally { + PyEval_RestoreThread(thread_state); + } } } @catch (NSException *exception) { From e3e2748460ca3d2e7f60c10bd49b1610bf29844b Mon Sep 17 00:00:00 2001 From: Kattni Date: Sat, 17 Oct 2026 16:28:09 -0700 Subject: [PATCH 2/2] Advertise the UIKit GIL contract before loading Python apps --- tests/threading/README.md | 25 +++++++- tests/threading/tests/threadingprobe.py | 62 +++++++++++++++++-- .../{{ cookiecutter.class_name }}/main.m | 8 +++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/tests/threading/README.md b/tests/threading/README.md index c8eb5b6..88d43cc 100644 --- a/tests/threading/README.md +++ b/tests/threading/README.md @@ -2,7 +2,7 @@ This application returns from Python into `UIApplicationMain`, then starts a Python worker while UIKit is otherwise idle. The worker sleeps for 0.1 seconds and schedules a callback on the main thread. The test fails if the worker and callback cannot complete within one second. A three-second watchdog bounds a failure; there is no heartbeat before the verdict. -With the old launcher, `UIApplicationMain` keeps the GIL and the worker cannot resume until the watchdog fires. With the corrected launcher, both the worker and its callback complete promptly. The test uses released Rubicon 0.5.7 and a lifecycle that does not nest a run loop, so it isolates the native launcher change. +By default, the test uses released Rubicon 0.5.7 with a lifecycle that does not nest a run loop. It checks that the launcher exposes `sys._ios_app_main_releases_gil = True` before importing application code and actually releases the GIL while UIKit runs. The old launcher fails this test. From this directory, with Briefcase installed and an iOS Simulator available: @@ -11,3 +11,26 @@ briefcase run iOS --test --no-input -C 'template="../.."' ``` Use a fresh generated application after changing the template. Briefcase's `--update` option updates Python application code; it does not regenerate `main.m`. Use `-d ` to select a simulator. + +## Partial-upgrade compatibility + +`--lifecycle default` exercises the unmodified `iOSLifecycle()` constructor used by Toga. The test observes whether the real lifecycle start call returns, checks the advertised capability, and verifies prompt worker delivery on the main thread. + +Run each of these combinations, using Rubicon 0.5.7 for the old version and a wheel from the companion Rubicon PR for the new version: + +| Rubicon | Launcher | `--expect-nested` | `--expect-capability` | +| --- | --- | --- | --- | +| Old | Old | `yes` | `no` | +| Old | New | `yes` | `yes` | +| New | Old | `yes` | `no` | +| New | New | `no` | `yes` | + +For example, with a fresh project generated from the new launcher and a local new Rubicon wheel: + +```console +briefcase run iOS --test --no-input -r -C 'template="../.."' -C 'requires=["rubicon-objc @ file:///absolute/path/to/rubicon.whl"]' -- --lifecycle default --expect-nested no --expect-capability yes +``` + +Replace the template path with a checkout of the old template for old-launcher cases. Replace the requirement with `rubicon-objc==0.5.7` for old-Rubicon cases. Keep the same test source in all four runs. Changing only the Rubicon requirement can reuse a generated project with `-r`; changing the native launcher requires regenerating and rebuilding it. + +All four combinations must deliver callbacks promptly. Only new Rubicon with the new launcher should avoid nesting. No Toga change or application-level capability assignment is involved. diff --git a/tests/threading/tests/threadingprobe.py b/tests/threading/tests/threadingprobe.py index c67b95d..6967dea 100644 --- a/tests/threading/tests/threadingprobe.py +++ b/tests/threading/tests/threadingprobe.py @@ -1,10 +1,14 @@ -"""Run in UIApplicationMain, without a nested run loop or heartbeat.""" +"""Check worker delivery and lifecycle selection under a real UIKit host.""" +import argparse +import json +import sys import threading import time +from importlib.metadata import version from rubicon.objc import ObjCClass, objc_method -from rubicon.objc.eventloop import CFLifecycle, RubiconEventLoop +from rubicon.objc.eventloop import CFLifecycle, RubiconEventLoop, iOSLifecycle class NativeLifecycle(CFLifecycle): @@ -13,8 +17,33 @@ def start(self): pass +parser = argparse.ArgumentParser() +parser.add_argument("--lifecycle", choices=["native", "default"], default="native") +parser.add_argument("--expect-nested", choices=["yes", "no"], default="no") +parser.add_argument("--expect-capability", choices=["yes", "no"], default="yes") +args = parser.parse_args() + +# Capture the launcher's flag before the app has a chance to change it. +capability = getattr(sys, "_ios_app_main_releases_gil", None) +main_thread = threading.get_ident() loop = RubiconEventLoop() result = None +lifecycle_started = False +lifecycle_returned = False +lifecycle = iOSLifecycle() if args.lifecycle == "default" else NativeLifecycle() +original_start = lifecycle.start + + +def observed_start(): + global lifecycle_started, lifecycle_returned + lifecycle_started = True + original_start() + lifecycle_returned = True + + +# Observe the real start call: a nested NSRunLoop does not return during the test. +# Do not override the constructor arguments or the launcher's capability flag. +lifecycle.start = observed_start def report(): @@ -35,15 +64,36 @@ def start_probe(): def delivered(): elapsed = time.monotonic() - started - print(f"Worker and callback completed after {elapsed:.3f}s", flush=True) - finish(0 if elapsed < 1 else 1) + nested = not lifecycle_returned + passed = ( + lifecycle_started + and elapsed < 1 + and threading.get_ident() == main_thread + and nested == (args.expect_nested == "yes") + and (capability is True) == (args.expect_capability == "yes") + ) + print( + json.dumps({ + "rubicon": version("rubicon-objc"), + "capability": capability, + "nested": nested, + "elapsed": elapsed, + "passed": passed, + }), + flush=True, + ) + finish(0 if passed else 1) + + def watchdog(): + print("Worker callback did not complete before the watchdog", flush=True) + finish(1) def worker(): time.sleep(0.1) loop.call_soon_threadsafe(delivered) # There are no intervening main-thread timers to release the GIL by accident. - loop.call_later(3, finish, 1) + loop.call_later(3, watchdog) threading.Thread(target=worker, daemon=True).start() @@ -56,4 +106,4 @@ def application_didFinishLaunchingWithOptions_( return True -loop.run_forever_cooperatively(lifecycle=NativeLifecycle()) +loop.run_forever_cooperatively(lifecycle=lifecycle) diff --git a/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m b/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m index 2d60670..1ba53b5 100644 --- a/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m +++ b/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m @@ -167,6 +167,14 @@ int main(int argc, char *argv[]) { } @try { + // Advertise the launcher's GIL contract before importing app code. + // Rubicon can then let UIKit own the run loop without nesting. + // PySys_SetObject keeps its own reference; Py_True is not stolen. + if (PySys_SetObject("_ios_app_main_releases_gil", Py_True) < 0) { + crash_dialog(@"Could not advertise the UIKit GIL capability"); + exit(-16); + } + // Adding the app_packages as site directory. // // This adds app_packages to sys.path and executes any .pth