From 5934039590a9092c567b2b1077c568b780bec76a Mon Sep 17 00:00:00 2001 From: Alexander Botkin Date: Mon, 9 Feb 2026 18:28:34 -0800 Subject: [PATCH 1/3] Plover 5.1.0 hangs on open on Intel macOS Tahoe (#1805) --- .github/workflows/ci.yml | 2 +- .github/workflows/ci/workflow_template.yml | 2 +- NEWS.md | 17 ++++ doc/conf.py | 2 +- osx/check_universal.sh | 58 ++++++++++++ osx/find_non_universal_wheels.py | 105 +++++++++++++++++++++ osx/make_app.sh | 30 +++++- osx/notarize_app.sh | 2 + plover/__init__.py | 2 +- plover_build_utils/install_wheels.py | 22 ++++- 10 files changed, 235 insertions(+), 7 deletions(-) create mode 100755 osx/check_universal.sh create mode 100644 osx/find_non_universal_wheels.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ac6b804..96e9d78ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1410,7 +1410,7 @@ jobs: cp dist/Wheel/*.whl dist/pypi/ - name: Publish release to PyPI (Trusted Publishing) - if: needs.analyze.outputs.release_type == 'tagged' + if: needs.analyze.outputs.release_type == 'tagged' && github.event.repository.fork == false uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/pypi diff --git a/.github/workflows/ci/workflow_template.yml b/.github/workflows/ci/workflow_template.yml index 086e6627e..71d70c927 100644 --- a/.github/workflows/ci/workflow_template.yml +++ b/.github/workflows/ci/workflow_template.yml @@ -459,7 +459,7 @@ jobs: cp dist/Wheel/*.whl dist/pypi/ - name: Publish release to PyPI (Trusted Publishing) - if: needs.analyze.outputs.release_type == 'tagged' + if: needs.analyze.outputs.release_type == 'tagged' && github.event.repository.fork == false uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/pypi diff --git a/NEWS.md b/NEWS.md index a9b7ef326..592a888cf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,20 @@ +# v5.3.0 (2026-02-09) + + +## Features + +No significant changes. + +## Bugfixes + +### macOS + +- Fixed startup hang on Intel Macs. (#1805) + +## API + +No significant changes. + # v5.2.1 (2026-02-06) diff --git a/doc/conf.py b/doc/conf.py index 67ec2031b..ae225b5a8 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -9,7 +9,7 @@ copyright = "Open Steno Project" author = copyright -release = "5.2.1" +release = "5.3.0" version = release # -- General configuration --------------------------------------------------- diff --git a/osx/check_universal.sh b/osx/check_universal.sh new file mode 100755 index 000000000..f31ab5fc7 --- /dev/null +++ b/osx/check_universal.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Verify all binaries in a Python.framework are universal (arm64 + x86_64). +# Usage: check_universal.sh +# Example: check_universal.sh .../Python.framework 3.13 + +set -e + +if [ $# -ne 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +framework_path="$1" +python_version="$2" + +if [[ $python_version != *"3."* ]]; then + echo "Invalid Python version: $python_version" + exit 1 +fi + +STATUS=0 + +# Ensure all .so and .dylib files are universal. +LIB_COUNT=$(find "$framework_path" -name "*.so" -or -name "*.dylib" | wc -l) +UNIVERSAL_COUNT=$(find "$framework_path" -name "*.so" -or -name "*.dylib" | xargs file | grep "2 architectures" | wc -l) +if [ "$LIB_COUNT" != "$UNIVERSAL_COUNT" ]; then + echo "$LIB_COUNT libraries (*.so and *.dylib) found in the framework; only $UNIVERSAL_COUNT are universal!" + echo "The following libraries are not universal:" + find "$framework_path" -name "*.so" -or -name "*.dylib" | xargs file | grep -v "2 architectures" | grep -v "(for architecture" + STATUS=1 +fi + +# Check key binaries in the framework. +KEY_BINARIES="$framework_path/Versions/Current/Python +$framework_path/Versions/Current/bin/python$python_version" + +for TESTFILE in $KEY_BINARIES; do + ARCH_TEST=$(file "$TESTFILE" | grep "2 architectures") + if [ "$ARCH_TEST" == "" ]; then + echo "$TESTFILE is not universal!" + STATUS=1 + fi +done + +# The Python.app binary may have been moved by make_app.sh (line 40) before +# this script runs, so skip it with a warning if absent. +PYTHON_APP_BINARY="$framework_path/Versions/$python_version/Resources/Python.app/Contents/MacOS/Python" +if [ ! -f "$PYTHON_APP_BINARY" ]; then + echo "Warning: $PYTHON_APP_BINARY not found (likely moved by make_app.sh), skipping." +else + ARCH_TEST=$(file "$PYTHON_APP_BINARY" | grep "2 architectures") + if [ "$ARCH_TEST" == "" ]; then + echo "$PYTHON_APP_BINARY is not universal!" + STATUS=1 + fi +fi + +[[ $STATUS == 0 ]] && echo "All files are universal!" || exit $STATUS diff --git a/osx/find_non_universal_wheels.py b/osx/find_non_universal_wheels.py new file mode 100644 index 000000000..0d8bf15d6 --- /dev/null +++ b/osx/find_non_universal_wheels.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Query PyPI to find packages that have macOS-specific binary wheels but lack +a universal2 variant for the given Python version. + +Outputs a comma-separated list suitable for pip's ``--no-binary`` flag. +Diagnostic details are printed to stderr. + +Usage: find_non_universal_wheels.py +Example: find_non_universal_wheels.py 3.13 reqs/constraints.txt +""" + +import json +import sys +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + + +def parse_constraints(path): + """Return {normalized_name: version} from a pip constraints file.""" + result = {} + for line in Path(path).read_text().splitlines(): + line = line.split("#")[0].strip() + if not line or line.startswith("-"): + continue + if "==" in line: + name, version = line.split("==", 1) + result[name.strip().lower()] = version.strip() + return result + + +def check_package(name, version, py_version): + """Return the package name if it needs ``--no-binary``, else ``None``. + + A package needs ``--no-binary`` when PyPI hosts macOS wheels for our + CPython version (or stable ABI) but none of them are universal2. + Packages with *no* macOS wheels at all are fine — they are either pure + Python or will be built from source automatically. + """ + url = f"https://pypi.org/pypi/{name}/{version}/json" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + except Exception: + return None # Network error — assume OK; check_universal.sh is the safety net. + + cp_tag = f"cp{py_version.replace('.', '')}" + has_macos_wheel = False + has_universal2 = False + + for file_info in data.get("urls", []): + fn = file_info.get("filename", "") + if "macosx" not in fn: + continue + # Must be compatible with our Python version (exact cpXY or stable ABI). + if cp_tag not in fn and "abi3" not in fn: + continue + has_macos_wheel = True + if "universal2" in fn: + has_universal2 = True + break + + if has_macos_wheel and not has_universal2: + return name + return None + + +def main(): + if len(sys.argv) != 3: + print( + f"Usage: {sys.argv[0]} ", + file=sys.stderr, + ) + sys.exit(1) + + py_version = sys.argv[1] # e.g. "3.13" + constraints = parse_constraints(sys.argv[2]) + + no_binary = [] + with ThreadPoolExecutor(max_workers=8) as pool: + futures = { + pool.submit(check_package, name, version, py_version): name + for name, version in constraints.items() + } + for future in as_completed(futures): + result = future.result() + if result: + no_binary.append(result) + + no_binary.sort() + for pkg in no_binary: + version = constraints.get(pkg, "?") + print( + f" {pkg}=={version}: no universal2 wheel on PyPI, will build from source", + file=sys.stderr, + ) + + # stdout: comma-separated list consumed by make_app.sh + if no_binary: + print(",".join(no_binary)) + + +if __name__ == "__main__": + main() diff --git a/osx/make_app.sh b/osx/make_app.sh index f406ef605..ba80ce656 100644 --- a/osx/make_app.sh +++ b/osx/make_app.sh @@ -62,8 +62,36 @@ run_eval "export SSL_CERT_FILE='$SSL_CERT_FILE'" run_eval "unset __PYVENV_LAUNCHER__" python='appdir_python' +# Ensure pip prefers universal2 wheels and source builds target both architectures. +export _PYTHON_HOST_PLATFORM="macosx-${py_installer_macos}.0-universal2" +export ARCHFLAGS="-arch x86_64 -arch arm64" + +# Remove single-architecture macOS wheels from the cache. The tox dev environment +# shares .cache/wheels/ and populates it with host-arch-only wheels during its own +# dependency installation, before make_app.sh runs. Removing them forces pip to +# re-download universal2 wheels or rebuild from source with ARCHFLAGS. +for whl in .cache/wheels/*.whl; do + [ -f "$whl" ] || continue + case "$(basename "$whl")" in + *universal2*) ;; + *macosx*) echo "Removing single-arch cached wheel: $whl"; rm -f "$whl" ;; + esac +done + +# Determine which packages lack universal2 wheels and must be built from source. +echo "Checking PyPI for universal2 wheel availability..." +no_binary_list=$(python3 osx/find_non_universal_wheels.py "${py_version%.*}" reqs/constraints.txt) +extra_args=(--no-cache-dir) +if [ -n "$no_binary_list" ]; then + echo "Packages requiring source builds for universal2: $no_binary_list" + extra_args+=(--no-binary "$no_binary_list") +fi + # Install Plover and dependencies. -bootstrap_dist "$plover_wheel" +bootstrap_dist "$plover_wheel" "${extra_args[@]}" + +# Verify all installed binaries are universal. +run bash osx/check_universal.sh "$frameworks_dir/Python.framework" "${py_version%.*}" # Create launcher. run gcc -Wall -O2 -arch x86_64 -arch arm64 'osx/app_resources/plover_launcher.c' -o "$macos_dir/Plover" diff --git a/osx/notarize_app.sh b/osx/notarize_app.sh index 835b5fdf7..19130abaa 100644 --- a/osx/notarize_app.sh +++ b/osx/notarize_app.sh @@ -37,6 +37,8 @@ cat >"$ent_plist" <<'EOS' + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation diff --git a/plover/__init__.py b/plover/__init__.py index a523e4d1b..e8bbb6174 100644 --- a/plover/__init__.py +++ b/plover/__init__.py @@ -15,7 +15,7 @@ def _(s): return s -__version__ = "5.2.1" +__version__ = "5.3.0" __copyright__ = "(C) Open Steno Project" __url__ = "http://www.openstenoproject.org/" __download_url__ = "http://www.openstenoproject.org/plover" diff --git a/plover_build_utils/install_wheels.py b/plover_build_utils/install_wheels.py index 3177148ac..53b1640e9 100644 --- a/plover_build_utils/install_wheels.py +++ b/plover_build_utils/install_wheels.py @@ -52,6 +52,14 @@ def _split_opts(text): """ ) +# Allowed `pip wheel` only options (not passed to `pip install`). +_PIP_WHEEL_OPTS = _split_opts( + """ + --no-binary 1 + --only-binary 1 + """ +) + # Allowed `pip install` only options. _PIP_INSTALL_OPTS = _split_opts( """ @@ -121,16 +129,26 @@ def install_wheels( if opt in _PIP_OPTS: nb_args = _PIP_OPTS[opt] install_only = False + wheel_only = False + elif opt in _PIP_WHEEL_OPTS: + nb_args = _PIP_WHEEL_OPTS[opt] + install_only = False + wheel_only = True elif opt in _PIP_INSTALL_OPTS: nb_args = _PIP_INSTALL_OPTS[opt] install_only = True + wheel_only = False else: raise ValueError("unsupported option: %s" % opt) a = [opt] + args[:nb_args] del args[:nb_args] - if not install_only: + if wheel_only: + wheel_args.extend(a) + elif not install_only: wheel_args.extend(a) - install_args.extend(a) + install_args.extend(a) + else: + install_args.extend(a) wheel_args[0:0] = ["wheel", "-f", wheels_cache, "-w", wheels_cache] install_args[0:0] = ["install", "--no-index", "--no-cache-dir", "-f", wheels_cache] pip_kwargs = dict(pip_install=pip_install, no_progress=no_progress) From 58934c9148de129f60700253d37ccd3013e22b3f Mon Sep 17 00:00:00 2001 From: Alexander Botkin Date: Mon, 9 Feb 2026 18:35:19 -0800 Subject: [PATCH 2/3] Remove the version update and direct edit of NEWS.md --- NEWS.md | 17 ----------------- doc/conf.py | 2 +- plover/__init__.py | 2 +- 3 files changed, 2 insertions(+), 19 deletions(-) diff --git a/NEWS.md b/NEWS.md index 592a888cf..a9b7ef326 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,20 +1,3 @@ -# v5.3.0 (2026-02-09) - - -## Features - -No significant changes. - -## Bugfixes - -### macOS - -- Fixed startup hang on Intel Macs. (#1805) - -## API - -No significant changes. - # v5.2.1 (2026-02-06) diff --git a/doc/conf.py b/doc/conf.py index ae225b5a8..67ec2031b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -9,7 +9,7 @@ copyright = "Open Steno Project" author = copyright -release = "5.3.0" +release = "5.2.1" version = release # -- General configuration --------------------------------------------------- diff --git a/plover/__init__.py b/plover/__init__.py index e8bbb6174..a523e4d1b 100644 --- a/plover/__init__.py +++ b/plover/__init__.py @@ -15,7 +15,7 @@ def _(s): return s -__version__ = "5.3.0" +__version__ = "5.2.1" __copyright__ = "(C) Open Steno Project" __url__ = "http://www.openstenoproject.org/" __download_url__ = "http://www.openstenoproject.org/plover" From 3d0482647b29619cc79ed35455cc23aa8ae9aeb5 Mon Sep 17 00:00:00 2001 From: Alexander Botkin Date: Mon, 9 Feb 2026 18:50:10 -0800 Subject: [PATCH 3/3] Add news element --- news.d/bugfix/1820.osx.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news.d/bugfix/1820.osx.md diff --git a/news.d/bugfix/1820.osx.md b/news.d/bugfix/1820.osx.md new file mode 100644 index 000000000..37a1957bf --- /dev/null +++ b/news.d/bugfix/1820.osx.md @@ -0,0 +1 @@ +Fixed startup hang when launching Plover on Intel Macs