diff --git a/.github/workflows/ios-crash-screenshot-no-freeze.yml b/.github/workflows/ios-crash-screenshot-no-freeze.yml new file mode 100644 index 0000000000..3fb265df8d --- /dev/null +++ b/.github/workflows/ios-crash-screenshot-no-freeze.yml @@ -0,0 +1,66 @@ +name: iOS Crash Screenshot No-Freeze + +# Regression guard for the sentry-cocoa crash-handler freeze (getsentry/sentry-cocoa#9281): +# crashing off the main thread with the UIScene lifecycle + a Swift (MainActor) scene delegate + +# attachScreenshot must NOT hang the crash handler. See +# scripts/verify-ios-crash-screenshot-no-freeze.sh for the full mechanism. +# +# It validates whatever sentry-cocoa the SDK currently bundles, so it is wired to run when the +# bundled cocoa version changes (the podspec / version table) and on manual dispatch. It will be +# RED until the bundled cocoa is bumped to a release containing the fix — that is the point: the +# cocoa-bump PR turns it green. + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "packages/core/RNSentry.podspec" + - "packages/core/scripts/sentry_utils.rb" + - ".github/workflows/ios-crash-screenshot-no-freeze.yml" + - "scripts/verify-ios-crash-screenshot-no-freeze.sh" + - "samples/expo/plugins/withIosSceneLifecycle.js" + pull_request: + paths: + - "packages/core/RNSentry.podspec" + - "packages/core/scripts/sentry_utils.rb" + - ".github/workflows/ios-crash-screenshot-no-freeze.yml" + - "scripts/verify-ios-crash-screenshot-no-freeze.sh" + - "samples/expo/plugins/withIosSceneLifecycle.js" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + verify: + name: Verify crash-time screenshot does not freeze + runs-on: macos-26-xlarge + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Enable Corepack + run: corepack enable + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + package-manager-cache: false + node-version: 20 + cache: "yarn" + cache-dependency-path: yarn.lock + + - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1 + with: + working-directory: samples/expo + ruby-version: "3.3.0" + bundler-cache: true + cache-version: 1 + + - name: Install SDK Dependencies + run: yarn install + + - name: Build SDK + run: yarn build + + - name: Verify crash-time screenshot does not freeze the crash handler + run: scripts/verify-ios-crash-screenshot-no-freeze.sh diff --git a/samples/expo/AGENTS.md b/samples/expo/AGENTS.md index fb2193c7b7..e1aaeaa44d 100644 --- a/samples/expo/AGENTS.md +++ b/samples/expo/AGENTS.md @@ -11,3 +11,13 @@ yarn android # Build & run the Android dev client (expo run:android) ``` `yarn start` then follows the Expo CLI prompts to open on an iOS simulator, Android emulator, or a physical device. Because the SDK ships native code, plain Expo Go isn't enough — the `run:*` scripts build a dev client that includes it. + +## iOS UIScene lifecycle + +The `plugins/withIosSceneLifecycle.js` config plugin makes this app adopt the iOS **UIScene lifecycle** with Expo's own `EXExpoAppSceneDelegate` (a Swift `UIWindowSceneDelegate`). This is where Apple/Expo are pushing every app ("Required by iOS 27"), and — importantly — it is the exact configuration behind the crash-handler freeze in [sentry-cocoa#9281](https://github.com/getsentry/sentry-cocoa/issues/9281). Keeping it as the sample's normal config means that bug class stays exercised by regular iOS CI. Expo 57 ships `ExpoAppSceneDelegate` but does not wire it up during prebuild yet, so the plugin adds the `UIApplicationSceneManifest` and adjusts the generated `AppDelegate`. + +### Crash-handler regression check + +The same plugin injects a documented test hook: launching the app with the env var **`SENTRY_TEST_ABORT=1`** crashes it via `abort()` on a **background thread** ~8s after launch. That makes SentryCrash's inline signal handler run off the main thread, forcing the crash-time screenshot (`attachScreenshot: true`) to read the MainActor scene-delegate `window` getter off-main — which **hangs** on an unfixed sentry-cocoa and **terminates cleanly** on a fixed one. The hook is inert unless the env var is set. + +`scripts/verify-ios-crash-screenshot-no-freeze.sh` (repo root) drives this end to end against whatever cocoa the SDK currently bundles, asserting a clean terminate + a captured `screenshot.png` with no `dispatch_assert_queue_fail` re-entry. It needs **Xcode 27 / iOS 26+** (Swift 6 isolation enforcement). To validate a local cocoa build before it is released, stage it and pass `SENTRY_XCFRAMEWORK_CACHE_DIR=` (containing `/Sentry.xcframework`). The `.github/workflows/ios-crash-screenshot-no-freeze.yml` workflow runs it on a cocoa version bump (and on manual dispatch) — it stays red until the bundled cocoa carries the fix. diff --git a/samples/expo/app.json b/samples/expo/app.json index 408725e684..5eb45f1029 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -89,7 +89,8 @@ } } ], - "expo-web-browser" + "expo-web-browser", + "./plugins/withIosSceneLifecycle" ], "extra": { "router": { diff --git a/samples/expo/plugins/withIosSceneLifecycle.js b/samples/expo/plugins/withIosSceneLifecycle.js new file mode 100644 index 0000000000..ce21f1a230 --- /dev/null +++ b/samples/expo/plugins/withIosSceneLifecycle.js @@ -0,0 +1,93 @@ +/** + * Makes the Expo sample adopt the iOS UIScene lifecycle with Expo's own `EXExpoAppSceneDelegate` + * (a Swift `UIWindowSceneDelegate`). This is the direction Apple/Expo are pushing every app + * ("Required by iOS 27"), and it is the exact configuration behind the crash-handler freeze in + * getsentry/sentry-cocoa#9281 — so by being the sample's normal config, it keeps that bug class + * exercised by regular CI. + * + * Expo 57 ships `ExpoAppSceneDelegate` but does not yet wire it up during prebuild, so this plugin + * does it: + * 1. Adds a `UIApplicationSceneManifest` to Info.plist pointing at `EXExpoAppSceneDelegate`. + * 2. Makes the generated `AppDelegate` conform to `ExpoReactNativeFactoryProvider` and defer the + * window / React Native start to the scene delegate. + * + * It also injects a small, documented test hook used by the crash-handler regression check + * (scripts/verify-ios-crash-screenshot-no-freeze.sh): when the app is launched with the runtime + * env var `SENTRY_TEST_ABORT=1`, it crashes via `abort()` on a background thread a few seconds + * after launch. SentryCrash then handles the signal off the main thread, forcing the crash-time + * screenshot to read the MainActor `window` getter off-main — which hangs on an unfixed cocoa and + * terminates cleanly on a fixed one. The hook is inert unless that env var is set, so it never + * affects normal runs. + */ +const { withInfoPlist, withAppDelegate } = require('@expo/config-plugins'); + +const SCENE_DELEGATE_CLASS = 'EXExpoAppSceneDelegate'; + +function withSceneManifest(config) { + return withInfoPlist(config, cfg => { + cfg.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: SCENE_DELEGATE_CLASS, + }, + ], + }, + }; + return cfg; + }); +} + +function patchAppDelegate(contents) { + let out = contents; + + // Conform to ExpoReactNativeFactoryProvider so the scene delegate can retrieve the factory the + // app delegate creates in didFinishLaunching. + const classDecl = 'class AppDelegate: ExpoAppDelegate {'; + if (!out.includes(classDecl)) { + throw new Error('[withIosSceneLifecycle] AppDelegate class declaration not found; Expo template changed.'); + } + out = out.replace(classDecl, 'class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {'); + + // Remove the app-delegate window creation + RN start; under the scene lifecycle the scene + // delegate (ExpoAppSceneDelegate) owns the window and starts React Native. + const windowBlock = /#if os\(iOS\) \|\| os\(tvOS\)[\s\S]*?factory\.startReactNative\([\s\S]*?\)\s*#endif/; + if (!windowBlock.test(out)) { + throw new Error('[withIosSceneLifecycle] window/startReactNative block not found; Expo template changed.'); + } + out = out.replace( + windowBlock, + [ + '// [withIosSceneLifecycle] Scene lifecycle: ExpoAppSceneDelegate creates the window and', + ' // starts React Native in scene(_:willConnectTo:). Do NOT start it here.', + '', + ' // Test hook for the crash-handler regression check (sentry-cocoa#9281): when launched', + ' // with SENTRY_TEST_ABORT=1, crash on a background thread so SentryCrash\'s inline handler', + ' // runs off the main thread and the crash-time screenshot reads the MainActor window', + ' // getter off-main. Inert unless the env var is set.', + ' if ProcessInfo.processInfo.environment["SENTRY_TEST_ABORT"] == "1" {', + ' DispatchQueue.global().asyncAfter(deadline: .now() + 8.0) { abort() }', + ' }', + ].join('\n'), + ); + + return out; +} + +function withSceneAppDelegate(config) { + return withAppDelegate(config, cfg => { + if (cfg.modResults.language !== 'swift') { + throw new Error('[withIosSceneLifecycle] expected a Swift AppDelegate.'); + } + cfg.modResults.contents = patchAppDelegate(cfg.modResults.contents); + return cfg; + }); +} + +module.exports = function withIosSceneLifecycle(config) { + config = withSceneManifest(config); + config = withSceneAppDelegate(config); + return config; +}; diff --git a/scripts/verify-ios-crash-screenshot-no-freeze.sh b/scripts/verify-ios-crash-screenshot-no-freeze.sh new file mode 100755 index 0000000000..48c1810012 --- /dev/null +++ b/scripts/verify-ios-crash-screenshot-no-freeze.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# Regression check for the sentry-cocoa crash-handler freeze (issue getsentry/sentry-cocoa#9281). +# +# With the UIScene lifecycle + a Swift (MainActor) scene delegate + `attachScreenshot`, an unfixed +# sentry-cocoa reads the scene delegate's `window` getter off the crash thread, trips +# `dispatch_assert_queue(main)`, re-enters the signal handler, and HANGS on non-Mach crashes +# (abort/terminate). A fixed cocoa reads `UIWindowScene.windows` instead and terminates cleanly. +# +# This script drives the Expo sample (with the env-gated `withSceneCrashRepro` plugin), crashes it +# off the main thread, and ASSERTS the process terminated cleanly, wrote a crash report, and still +# captured a screenshot — with no `dispatch_assert_queue_fail` re-entry. +# +# It validates whatever sentry-cocoa the SDK currently bundles, so it goes green once the bundled +# cocoa version is bumped to a release that contains the fix. To validate a local cocoa build before +# release, stage it and pass its cache dir: +# SENTRY_XCFRAMEWORK_CACHE_DIR=/path/to/cache (containing /Sentry.xcframework) ... +# +# Usage: scripts/verify-ios-crash-screenshot-no-freeze.sh +# Exit code: 0 = fixed (clean terminate + screenshot), 1 = bug present or setup failure. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EXPO_DIR="$REPO_ROOT/samples/expo" +IOS_DIR="$EXPO_DIR/ios" +BUNDLE_ID="io.sentry.expo.sample" +WORKDIR="${WORKDIR:-$(mktemp -d)}" +DD="$WORKDIR/DerivedData" +ABORT_DELAY_PADDING=20 # seconds to wait past the 8s in-app abort before declaring a freeze + +log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +fail() { printf '\n\033[31mFAIL: %s\033[0m\n' "$*" >&2; exit 1; } + +# ---- 1. Pick an iOS 26+ simulator (Swift 6 isolation enforcement needs the iOS 26+ runtime) ------- +log "Selecting an iOS 26+ simulator" +SIM_ID="${SIM_ID:-$(xcrun simctl list devices available --json 2>/dev/null | python3 -c ' +import json, sys +data = json.load(sys.stdin)["devices"] +for runtime, devices in sorted(data.items()): + # runtime looks like com.apple.CoreSimulator.SimRuntime.iOS-26-5 + key = runtime.rsplit(".", 1)[-1] + if not key.startswith("iOS-"): + continue + try: + major = int(key.split("-")[1]) + except (IndexError, ValueError): + continue + if major < 26: + continue + for d in devices: + if d.get("isAvailable") and "iPhone" in d.get("name", ""): + print(d["udid"]); sys.exit(0) +')}" +[ -n "$SIM_ID" ] || fail "No iOS 26+ iPhone simulator available (required for Swift 6 isolation enforcement)." +echo "Simulator: $SIM_ID" +xcrun simctl boot "$SIM_ID" 2>/dev/null || true + +# ---- 2. Prebuild the sample (the withIosSceneLifecycle plugin adopts the UIScene lifecycle) ------- +log "Prebuilding Expo sample" +( cd "$EXPO_DIR" && CI=1 npx expo prebuild --clean --no-install -p ios ) + +# Guard: fail loudly if the scene lifecycle did not get wired up (otherwise the check would pass +# trivially against a non-scene app that cannot hit the bug). +/usr/libexec/PlistBuddy -c "Print :UIApplicationSceneManifest:UISceneConfigurations:UIWindowSceneSessionRoleApplication:0:UISceneDelegateClassName" \ + "$IOS_DIR/sentryreactnativeexposample/Info.plist" 2>/dev/null | grep -q EXExpoAppSceneDelegate \ + || fail "Scene manifest not injected — withIosSceneLifecycle did not apply (Expo template drift?)." +grep -q "ExpoReactNativeFactoryProvider" "$IOS_DIR/sentryreactnativeexposample/AppDelegate.swift" \ + || fail "AppDelegate not wired for the scene lifecycle — withIosSceneLifecycle did not apply." + +# ---- 3. Pod install (honors SENTRY_XCFRAMEWORK_CACHE_DIR for local pre-release validation) -------- +log "Installing pods" +( cd "$IOS_DIR" && export REACT_NATIVE_NODE_MODULES_DIR="$(cd ../node_modules/react-native && pwd)" && pod install ) +echo "Bundled cocoa:"; grep -m2 "Sentry (" "$IOS_DIR/Podfile.lock" || true + +# ---- 4. Release build for the simulator (bundles JS; attachScreenshot is configured natively) ---- +log "Building Release app for the simulator" +( cd "$IOS_DIR" && SENTRY_DISABLE_AUTO_UPLOAD=true TOOLCHAINS=com.apple.dt.toolchain.XcodeDefault xcodebuild build \ + -workspace sentryreactnativeexposample.xcworkspace \ + -scheme sentryreactnativeexposample \ + -configuration Release -sdk iphonesimulator \ + -destination "platform=iOS Simulator,id=$SIM_ID" \ + -derivedDataPath "$DD" ONLY_ACTIVE_ARCH=yes ARCHS=arm64 CODE_SIGNING_ALLOWED=NO ) +APP="$(find "$DD/Build/Products/Release-iphonesimulator" -maxdepth 1 -name '*.app' | head -1)" +[ -d "$APP" ] || fail "Build did not produce an .app" + +# ---- 5. Install, crash off-main, and classify ---------------------------------------------------- +log "Running the crash and classifying the outcome" +xcrun simctl terminate "$SIM_ID" "$BUNDLE_ID" 2>/dev/null || true +xcrun simctl uninstall "$SIM_ID" "$BUNDLE_ID" 2>/dev/null || true +xcrun simctl install "$SIM_ID" "$APP" +PID="$(SIMCTL_CHILD_SENTRY_TEST_ABORT=1 xcrun simctl launch "$SIM_ID" "$BUNDLE_ID" | awk -F': ' '{print $2}')" +echo "Launched pid=$PID (abort scheduled ~8s after launch)" + +TERMINATED=0 +for _ in $(seq 1 $((8 + ABORT_DELAY_PADDING))); do + sleep 1 + ps -p "$PID" >/dev/null 2>&1 || { TERMINATED=1; break; } +done + +if [ "$TERMINATED" -eq 0 ]; then + echo "Process $PID still alive long after the abort — capturing the hung stack:" + sample "$PID" 2 -file "$WORKDIR/sample.txt" 2>/dev/null || true + grep -iE "dispatch_assert_queue|isCurrentExecutor|collectWindowsOnCurrentThread|saveScreenShots|window.getter" \ + "$WORKDIR/sample.txt" | sort -u | head + xcrun simctl terminate "$SIM_ID" "$BUNDLE_ID" 2>/dev/null || true + fail "App FROZE during crash handling — the bundled sentry-cocoa still has issue #9281." +fi +echo "Process terminated cleanly after the crash." + +# ---- 6. Assert the crash report + screenshot were produced, with no trap re-entry ---------------- +log "Validating crash artifacts" +DATA="$(xcrun simctl get_app_container "$SIM_ID" "$BUNDLE_ID" data)" +REPORTS="$DATA/Library/Caches/SentryCrash/sentryreactnativeexposample/Reports" +REPORT="$(ls "$REPORTS"/*.json 2>/dev/null | head -1 || true)" +SHOT="$(find "$REPORTS" -name 'screenshot.png' 2>/dev/null | head -1 || true)" + +[ -n "$REPORT" ] || fail "No crash report was written." +[ -n "$SHOT" ] && [ -s "$SHOT" ] || fail "No (non-empty) crash-time screenshot was captured." +file "$SHOT" | grep -q "PNG image data" || fail "Screenshot is not a valid PNG." +if grep -q "dispatch_assert_queue_fail" "$REPORT"; then + fail "Crash report contains a dispatch_assert_queue_fail re-entry — the trap still fires." +fi + +echo "Crash report: $(basename "$REPORT")" +echo "Screenshot: $(file -b "$SHOT")" +log "PASS: clean terminate + screenshot captured, no crash-handler freeze."