Repository navigation
test(e2e): iOS crash-handler screenshot no-freeze regression check #6839
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
antonis
wants to merge
9
commits into
main
Choose a base branch
from
test/ios-crash-screenshot-freeze-check
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
5182390
chore(expo): bump sample to Expo 57.0.26
antonis 5f49f59
test(e2e): add iOS crash-handler screenshot no-freeze regression check
antonis 3060deb
Merge remote-tracking branch 'origin/main' into test/ios-crash-screen…
antonis b64567f
Merge branch 'main' into test/ios-crash-screenshot-freeze-check
antonis 9599d52
Merge branch 'main' into test/ios-crash-screenshot-freeze-check
antonis 04e2cd9
test(e2e): Assert the no-freeze check exercises the off-main abort path
antonis 3766e7a
ci(e2e): Run the no-freeze check when the Expo sample config changes
antonis 0f7ede0
test(e2e): Keep the freeze diagnostic from aborting the no-freeze check
antonis b159725
ci(e2e): Gate the no-freeze check on the ready-to-merge label
antonis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| 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 (fixed since 9.30.1), so it is wired | ||
| # to run when the bundled cocoa version changes (the podspec / version table) and on manual dispatch. | ||
|
|
||
| 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" | ||
| - "samples/expo/app.json" | ||
| pull_request: | ||
| types: [opened, synchronize, reopened, labeled] | ||
| 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" | ||
| - "samples/expo/app.json" | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.ref }} | ||
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} | ||
|
|
||
| jobs: | ||
| ready-to-merge-gate: | ||
| name: Ready-to-merge gate | ||
| uses: ./.github/workflows/ready-to-merge-workflow.yml | ||
| with: | ||
| is-pr: ${{ github.event_name == 'pull_request' }} | ||
| labels: ${{ toJson(github.event.pull_request.labels) }} | ||
|
|
||
| verify: | ||
| needs: [ready-to-merge-gate] | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| #!/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 `SENTRY_TEST_ABORT` hook from `withIosSceneLifecycle`), | ||
| # 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 (the fix ships in 9.30.1). To validate | ||
| # a local cocoa build before release, stage it and pass its cache dir: | ||
| # SENTRY_XCFRAMEWORK_CACHE_DIR=/path/to/cache (containing <version>/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=8 # must match the in-app SENTRY_TEST_ABORT delay | ||
| ABORT_DELAY_PADDING=20 # seconds to wait past the in-app abort before declaring a freeze | ||
| MIN_ALIVE=$((ABORT_DELAY - 2)) # dying earlier means something else crashed the app | ||
|
|
||
| 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." | ||
| grep -q 'SENTRY_TEST_ABORT' "$IOS_DIR/sentryreactnativeexposample/AppDelegate.swift" \ | ||
| || fail "AppDelegate is missing the SENTRY_TEST_ABORT hook — 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" | ||
| LAUNCHED_AT=$SECONDS | ||
| PID="$(SIMCTL_CHILD_SENTRY_TEST_ABORT=1 xcrun simctl launch "$SIM_ID" "$BUNDLE_ID" | awk -F': ' '{print $2}')" | ||
| echo "Launched pid=$PID (abort scheduled ~${ABORT_DELAY}s after launch)" | ||
|
|
||
| TERMINATED=0 | ||
| for _ in $(seq 1 $((ABORT_DELAY + 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" 2>/dev/null | sort -u | head || true | ||
| 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 | ||
| ALIVE=$((SECONDS - LAUNCHED_AT)) | ||
| [ "$ALIVE" -ge "$MIN_ALIVE" ] \ | ||
| || fail "Process died after ${ALIVE}s, before the scheduled abort — an unrelated crash, not the off-main abort path." | ||
| echo "Process terminated cleanly ${ALIVE}s after launch." | ||
|
|
||
| # ---- 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." | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
| fi | ||
| # The report must be the scheduled background abort: SIGABRT on a non-main thread. | ||
| python3 - "$REPORT" <<'PY' || fail "Crash report is not a SIGABRT on a background thread — the off-main abort path was not exercised." | ||
| import json, sys | ||
| crash = json.load(open(sys.argv[1]))["crash"] | ||
| signal = crash["error"].get("signal", {}).get("signal") | ||
| crashed = [t for t in crash.get("threads", []) if t.get("crashed")] | ||
| print(f"signal={signal} crashed_thread={[t.get('index') for t in crashed]}") | ||
| sys.exit(0 if signal == 6 and crashed and crashed[0].get("index") != 0 else 1) | ||
| PY | ||
|
|
||
| echo "Crash report: $(basename "$REPORT")" | ||
| echo "Screenshot: $(file -b "$SHOT")" | ||
| log "PASS: clean terminate + screenshot captured, no crash-handler freeze." | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.