Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
});
```

### Fixes

- Fix race condition where iOS dSYM upload runs before debug symbols are fully generated ([#5653](https://github.com/getsentry/sentry-react-native/pull/5653))

### Dependencies

- Bump JavaScript SDK from v10.38.0 to v10.39.0 ([#5674](https://github.com/getsentry/sentry-react-native/pull/5674))
Expand Down
143 changes: 143 additions & 0 deletions packages/core/scripts/sentry-xcode-debug-files.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/bin/bash
# Upload Debug Symbols to Sentry Xcode Build Phase
# PWD=ios
#
# Configuration: See BUILD_CONFIGURATION.md for all available environment variables

# print commands before executing them
set -x
Expand Down Expand Up @@ -58,6 +60,141 @@ EXTRA_ARGS="$SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_DEBUG_FILES_UPLOAD_EXTRA_ARGS $IN

UPLOAD_DEBUG_FILES="\"$SENTRY_CLI_EXECUTABLE\" debug-files upload $EXTRA_ARGS \"$DWARF_DSYM_FOLDER_PATH\""

# Function to wait for dSYM files to be generated
# This addresses a race condition where the upload script runs before dSYM generation completes
wait_for_dsym_files() {
local max_attempts="${SENTRY_DSYM_WAIT_MAX_ATTEMPTS:-10}"
local wait_interval="${SENTRY_DSYM_WAIT_INTERVAL:-2}"
local attempt=1
local total_wait_time=0

# Check if we should wait for dSYM files
if [ "$SENTRY_DSYM_WAIT_ENABLED" == "false" ]; then
echo "SENTRY_DSYM_WAIT_ENABLED=false, skipping dSYM wait check"
return 0
fi

# Warn if DWARF_DSYM_FILE_NAME is not set - we can't verify the main app dSYM
if [ -z "$DWARF_DSYM_FILE_NAME" ]; then
echo "warning: DWARF_DSYM_FILE_NAME not set, cannot verify main app dSYM specifically"
echo "warning: Will proceed when any dSYM bundle is found"
fi

echo "Checking for dSYM files in: $DWARF_DSYM_FOLDER_PATH"

# Debug information to help diagnose issues
if [ -n "${SENTRY_DSYM_DEBUG}" ]; then
echo "DEBUG: DWARF_DSYM_FOLDER_PATH=$DWARF_DSYM_FOLDER_PATH"
echo "DEBUG: DWARF_DSYM_FILE_NAME=$DWARF_DSYM_FILE_NAME"
echo "DEBUG: PRODUCT_NAME=$PRODUCT_NAME"
if [ -d "$DWARF_DSYM_FOLDER_PATH" ]; then
echo "DEBUG: Contents of dSYM folder:"
ls -la "$DWARF_DSYM_FOLDER_PATH" 2>/dev/null || echo "Cannot list folder"
else
echo "DEBUG: dSYM folder does not exist yet"
fi
fi

while [ $attempt -le $max_attempts ]; do
# Check if the dSYM folder exists
if [ -d "$DWARF_DSYM_FOLDER_PATH" ]; then
# Check if there are any .dSYM bundles in the folder
local dsym_count=$(find "$DWARF_DSYM_FOLDER_PATH" -name "*.dSYM" -type d 2>/dev/null | wc -l | tr -d ' ')

if [ "$dsym_count" -gt 0 ]; then
echo "Found $dsym_count dSYM bundle(s) in $DWARF_DSYM_FOLDER_PATH"

# If DWARF_DSYM_FILE_NAME is set, verify the main app dSYM exists and is complete
if [ -n "$DWARF_DSYM_FILE_NAME" ]; then
local main_dsym="$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME"

if [ -d "$main_dsym" ]; then
# Directory exists, now verify the actual DWARF binary exists inside
local dwarf_dir="$main_dsym/Contents/Resources/DWARF"

if [ -d "$dwarf_dir" ]; then
# Check if there are any files in the DWARF directory
local dwarf_files=$(find "$dwarf_dir" -type f 2>/dev/null | head -1)

if [ -n "$dwarf_files" ]; then
# Verify the DWARF file is not empty (still being written)
local dwarf_size=$(find "$dwarf_dir" -type f -size +0 2>/dev/null | head -1)

if [ -n "$dwarf_size" ]; then
echo "Verified main app dSYM is complete: $DWARF_DSYM_FILE_NAME"
return 0
else
echo "Main app dSYM DWARF binary is empty (still being written): $DWARF_DSYM_FILE_NAME (attempt $attempt/$max_attempts)"
fi
else
echo "Main app dSYM DWARF directory is empty: $DWARF_DSYM_FILE_NAME (attempt $attempt/$max_attempts)"
fi
else
echo "Main app dSYM structure incomplete (missing DWARF directory): $DWARF_DSYM_FILE_NAME (attempt $attempt/$max_attempts)"
fi
else
echo "Main app dSYM not found yet: $DWARF_DSYM_FILE_NAME (attempt $attempt/$max_attempts)"
fi
else
# DWARF_DSYM_FILE_NAME not set, check if any dSYM has valid DWARF content
# This is less strict but better than nothing
local has_valid_dsym=false
for dsym in "$DWARF_DSYM_FOLDER_PATH"/*.dSYM; do
if [ -d "$dsym/Contents/Resources/DWARF" ]; then
local dwarf_files=$(find "$dsym/Contents/Resources/DWARF" -type f -size +0 2>/dev/null | head -1)
if [ -n "$dwarf_files" ]; then
has_valid_dsym=true
break
fi
fi
done

if [ "$has_valid_dsym" = true ]; then
echo "Found dSYM bundle(s) with valid DWARF content"
return 0
else
echo "Found dSYM bundle(s) but none have complete DWARF content yet (attempt $attempt/$max_attempts)"
fi
fi
else
echo "No dSYM bundles found yet in $DWARF_DSYM_FOLDER_PATH (attempt $attempt/$max_attempts)"
fi
else
echo "dSYM folder does not exist yet: $DWARF_DSYM_FOLDER_PATH (attempt $attempt/$max_attempts)"
fi

if [ $attempt -lt $max_attempts ]; then
# Progressive backoff: quick checks first, longer waits later
# Attempts 1-3: 0.5s (total 1.5s)
# Attempts 4-6: 1s (total 3s)
# Attempts 7+: 2s (remaining time)
local current_interval="$wait_interval"
if [ -z "${SENTRY_DSYM_WAIT_INTERVAL}" ]; then
# Only use progressive intervals if user hasn't set custom interval
if [ $attempt -le 3 ]; then
current_interval=0.5
elif [ $attempt -le 6 ]; then
current_interval=1
else
current_interval=2
fi
fi

echo "Waiting ${current_interval}s for dSYM generation to complete..."
sleep $current_interval
total_wait_time=$(awk "BEGIN {print $total_wait_time + $current_interval}")
fi

attempt=$((attempt + 1))
done

# Timeout reached
echo "warning: Timeout waiting for dSYM files after ${total_wait_time}s ($max_attempts attempts)"
echo "warning: This may result in incomplete debug symbol uploads"
echo "warning: To disable this check, set SENTRY_DSYM_WAIT_ENABLED=false"
return 1
}

XCODE_BUILD_CONFIGURATION="${CONFIGURATION}"

if [ "$SENTRY_DISABLE_AUTO_UPLOAD" == true ]; then
Expand All @@ -67,6 +204,12 @@ elif [ "$SENTRY_DISABLE_XCODE_DEBUG_UPLOAD" == true ]; then
elif echo "$XCODE_BUILD_CONFIGURATION" | grep -iq "debug"; then # case insensitive check for "debug"
echo "Skipping debug files upload for *Debug* configuration"
else
# Wait for dSYM files to be generated (addresses race condition in EAS builds)
# Don't fail the script if wait times out - we still want to attempt upload
set +e
wait_for_dsym_files
set -e

# 'warning:' triggers a warning in Xcode, 'error:' triggers an error
set +x +e # disable printing commands otherwise we might print `error:` by accident and allow continuing on error
SENTRY_UPLOAD_COMMAND_OUTPUT=$(/bin/sh -c "\"$LOCAL_NODE_BINARY\" $UPLOAD_DEBUG_FILES" 2>&1)
Expand Down
107 changes: 107 additions & 0 deletions packages/core/test/expo-plugin/modifyXcodeProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,110 @@ describe('Configures iOS native project correctly', () => {
expect(warnOnce).toHaveBeenCalled();
});
});

describe('Upload Debug Symbols to Sentry build phase', () => {
let mockXcodeProject: any;
let addBuildPhaseSpy: jest.Mock;
const expectedShellScript =
"/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";

const getOptions = () => {
const callArgs = addBuildPhaseSpy.mock.calls[0];
return callArgs[4];
};

beforeEach(() => {
addBuildPhaseSpy = jest.fn();
mockXcodeProject = {
pbxItemByComment: jest.fn().mockReturnValue(null),
addBuildPhase: addBuildPhaseSpy,
};
});

afterEach(() => {
jest.clearAllMocks();
});

it('creates Upload Debug Symbols build phase with correct shell script', () => {
mockXcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
shellPath: '/bin/sh',
shellScript: expectedShellScript,
});

expect(addBuildPhaseSpy).toHaveBeenCalledWith(
[],
'PBXShellScriptBuildPhase',
'Upload Debug Symbols to Sentry',
null,
{
shellPath: '/bin/sh',
shellScript: expectedShellScript,
},
);
});

it('does not include inputPaths to avoid circular dependency', () => {
// We don't use inputPaths because they cause circular dependency errors in Xcode 15+
// (see issue #5641). Instead, the bash script waits for dSYM files to be generated.
mockXcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
shellPath: '/bin/sh',
shellScript: expectedShellScript,
});

const options = getOptions();

expect(options.inputPaths).toBeUndefined();
});

it('skips creating build phase if it already exists', () => {
mockXcodeProject.pbxItemByComment = jest.fn().mockReturnValue({
shellScript: 'existing',
});

expect(addBuildPhaseSpy).not.toHaveBeenCalled();
});

describe('Race condition handling', () => {
it('documents why we do not use inputPaths', () => {
// This test documents the decision NOT to use inputPaths.
//
// ISSUE #5288: Race condition where upload script runs before dSYM generation completes
// ISSUE #5641: inputPaths cause circular dependency errors in Xcode 15+
//
// We attempted to fix #5288 by adding inputPaths to declare dependency on dSYM files:
// inputPaths: [
// '"$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Resources/DWARF/$(PRODUCT_NAME)"',
// '"$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)"',
// ]
//
// However, this caused Xcode 15+ to fail with:
// "Cycle inside X; building could produce unreliable results"
//
// The cycle occurs because:
// 1. The target produces the dSYM as an output during linking
// 2. The "Upload Debug Symbols" build phase (part of the same target) declares the dSYM as an input
// 3. Xcode detects: target depends on its own output = CYCLE
//
// SOLUTION: Instead of using inputPaths, the bash script (sentry-xcode-debug-files.sh)
// now waits for dSYM files to exist before uploading. This avoids the circular dependency
// while still handling the race condition.
//
// See:
// - https://github.com/getsentry/sentry-react-native/issues/5288
// - https://github.com/getsentry/sentry-react-native/issues/5641
// - https://developer.apple.com/forums/thread/730974

mockXcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
shellPath: '/bin/sh',
shellScript: expectedShellScript,
});

const options = getOptions();

// Verify that inputPaths are NOT used
expect(options.inputPaths).toBeUndefined();
expect(options.shellPath).toBe('/bin/sh');
expect(options.shellScript).toBe(expectedShellScript);
});
});
});
Loading
Loading