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..88d43cc --- /dev/null +++ b/tests/threading/README.md @@ -0,0 +1,36 @@ +# 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. + +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: + +```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. + +## 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/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..6967dea --- /dev/null +++ b/tests/threading/tests/threadingprobe.py @@ -0,0 +1,109 @@ +"""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, iOSLifecycle + + +class NativeLifecycle(CFLifecycle): + def start(self): + # UIApplicationMain starts the outer run loop after this module returns. + 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(): + 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 + 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, watchdog) + 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=lifecycle) diff --git a/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m b/{{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m index 1acb2f7..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 @@ -299,7 +307,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) {