Skip to content
Draft
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
66 changes: 66 additions & 0 deletions .github/workflows/ios-crash-screenshot-no-freeze.yml
Original file line number Diff line number Diff line change
@@ -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"

Check warning on line 29 in .github/workflows/ios-crash-screenshot-no-freeze.yml

View check run for this annotation

@sentry/warden / warden: find-bugs

[MPB-H6J] Workflow path filters omit app.json that enables the fixture (additional location)

Add `samples/expo/app.json` to the push and pull_request path filters so removing `./plugins/withIosSceneLifecycle` or `attachScreenshot: true` still runs this regression check instead of silently disabling it.

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
10 changes: 10 additions & 0 deletions samples/expo/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,14 @@
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`.

Check warning on line 18 in samples/expo/AGENTS.md

View check run for this annotation

@sentry/warden / warden: code-review

Expected-red workflow can fail unrelated Cocoa-path PR checks

The workflow explicitly expects to fail until the bundled cocoa fix is available, but its pull-request path filters also run it for changes to the podspec, version table, workflow, harness, or plugin. If this check is required, unrelated changes to those paths will be blocked while the known issue remains. Could you keep it non-required or gate the expected baseline failure until the fix ships?
### 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=<cache>` (containing `<version>/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.
3 changes: 2 additions & 1 deletion samples/expo/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@
}
}
],
"expo-web-browser"
"expo-web-browser",
"./plugins/withIosSceneLifecycle"
],
"extra": {
"router": {
Expand Down
93 changes: 93 additions & 0 deletions samples/expo/plugins/withIosSceneLifecycle.js
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;
};
126 changes: 126 additions & 0 deletions scripts/verify-ios-crash-screenshot-no-freeze.sh
Original file line number Diff line number Diff line change
@@ -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 <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_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."
Loading