Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
.vscode
.idea
.rumdl_cache

# Native launcher regression application
/tests/threading/build/
/tests/threading/logs/
/tests/threading/.briefcase/
36 changes: 36 additions & 0 deletions tests/threading/README.md
Original file line number Diff line number Diff line change
@@ -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 <simulator-UDID>` 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.
21 changes: 21 additions & 0 deletions tests/threading/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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
Empty file.
1 change: 1 addition & 0 deletions tests/threading/src/threadingprobe/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raise RuntimeError("Run this application with briefcase run iOS --test")
Empty file.
109 changes: 109 additions & 0 deletions tests/threading/tests/threadingprobe.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 19 additions & 1 deletion {{ cookiecutter.format }}/{{ cookiecutter.class_name }}/main.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading