Skip to content

Commit 8334e91

Browse files
antonisclaudelucas-zimerman
authored
fix(ios): Add wait logic for dSYM generation in Xcode build phase (#5653)
* fix(ios): Add wait logic for dSYM generation in Xcode build phase * fix(ios): Correct timeout calculation in dSYM wait logic Fixed the timeout warning message to report actual elapsed time instead of calculated max_attempts * wait_interval. With progressive backoff (0.5s, 1s, 2s), the old calculation was incorrect (e.g., reported 20s when actual was ~12.5s). Changes: - Track actual wait time with total_wait_time variable - Accumulate elapsed time after each sleep interval - Update timeout message to show actual time and attempt count - Add reference to BUILD_CONFIGURATION.md for configuration options Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Adds changelog * Remove test scripts * Remove reference to not committed file * refactor(ios): Extract dSYM check into helper to flatten nested ifs Replace the 6-level nested if pyramid in wait_for_dsym_files() with a _sentry_check_dsym_ready() helper that uses guard clauses (early return). Also fixes the local var=$(subshell) anti-pattern where `local` always returns 0 and would mask subshell failures under `set -e`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(ios): Gate verbose dSYM status messages behind SENTRY_DSYM_DEBUG Add _sentry_dsym_log() helper that only prints when SENTRY_DSYM_DEBUG is set. Per-attempt "not ready" messages (dSYM not found, DWARF empty, etc.) are now debug-only to reduce noise in normal builds. Success messages and timeout warnings remain always visible. Update tests to pass SENTRY_DSYM_DEBUG=true when asserting on the per-attempt debug messages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: LucasZF <lucas-zimerman1@hotmail.com>
1 parent 208264a commit 8334e91

File tree

4 files changed

+446
-0
lines changed

4 files changed

+446
-0
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
});
3737
```
3838

39+
### Fixes
40+
41+
- Fix race condition where iOS dSYM upload runs before debug symbols are fully generated ([#5653](https://github.com/getsentry/sentry-react-native/pull/5653))
42+
3943
### Dependencies
4044

4145
- Bump JavaScript SDK from v10.38.0 to v10.39.0 ([#5674](https://github.com/getsentry/sentry-react-native/pull/5674))

packages/core/scripts/sentry-xcode-debug-files.sh

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,144 @@ EXTRA_ARGS="$SENTRY_CLI_EXTRA_ARGS $SENTRY_CLI_DEBUG_FILES_UPLOAD_EXTRA_ARGS $IN
5858

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

61+
# Print a message only when SENTRY_DSYM_DEBUG is set
62+
_sentry_dsym_log() {
63+
if [ -n "${SENTRY_DSYM_DEBUG}" ]; then
64+
echo "$1"
65+
fi
66+
}
67+
68+
# Check if dSYM files are fully generated and ready to upload.
69+
# Returns 0 (ready) or 1 (not ready yet), printing a status message in either case.
70+
_sentry_check_dsym_ready() {
71+
local dsym_folder="$1"
72+
local dsym_file_name="$2"
73+
local attempt="$3"
74+
local max_attempts="$4"
75+
76+
if [ ! -d "$dsym_folder" ]; then
77+
_sentry_dsym_log "dSYM folder does not exist yet: $dsym_folder (attempt $attempt/$max_attempts)"
78+
return 1
79+
fi
80+
81+
local dsym_count
82+
dsym_count=$(find "$dsym_folder" -name "*.dSYM" -type d 2>/dev/null | wc -l | tr -d ' ')
83+
if [ "$dsym_count" -eq 0 ]; then
84+
_sentry_dsym_log "No dSYM bundles found yet in $dsym_folder (attempt $attempt/$max_attempts)"
85+
return 1
86+
fi
87+
88+
echo "Found $dsym_count dSYM bundle(s) in $dsym_folder"
89+
90+
# DWARF_DSYM_FILE_NAME not set: check if any dSYM has valid DWARF content
91+
if [ -z "$dsym_file_name" ]; then
92+
for dsym in "$dsym_folder"/*.dSYM; do
93+
local dwarf_file
94+
dwarf_file=$(find "$dsym/Contents/Resources/DWARF" -type f -size +0 2>/dev/null | head -1)
95+
if [ -n "$dwarf_file" ]; then
96+
echo "Found dSYM bundle(s) with valid DWARF content"
97+
return 0
98+
fi
99+
done
100+
_sentry_dsym_log "Found dSYM bundle(s) but none have complete DWARF content yet (attempt $attempt/$max_attempts)"
101+
return 1
102+
fi
103+
104+
# DWARF_DSYM_FILE_NAME set: verify the main app dSYM is complete
105+
local main_dsym="$dsym_folder/$dsym_file_name"
106+
if [ ! -d "$main_dsym" ]; then
107+
_sentry_dsym_log "Main app dSYM not found yet: $dsym_file_name (attempt $attempt/$max_attempts)"
108+
return 1
109+
fi
110+
111+
local dwarf_dir="$main_dsym/Contents/Resources/DWARF"
112+
if [ ! -d "$dwarf_dir" ]; then
113+
_sentry_dsym_log "Main app dSYM structure incomplete (missing DWARF directory): $dsym_file_name (attempt $attempt/$max_attempts)"
114+
return 1
115+
fi
116+
117+
local dwarf_files
118+
dwarf_files=$(find "$dwarf_dir" -type f 2>/dev/null | head -1)
119+
if [ -z "$dwarf_files" ]; then
120+
_sentry_dsym_log "Main app dSYM DWARF directory is empty: $dsym_file_name (attempt $attempt/$max_attempts)"
121+
return 1
122+
fi
123+
124+
local dwarf_size
125+
dwarf_size=$(find "$dwarf_dir" -type f -size +0 2>/dev/null | head -1)
126+
if [ -z "$dwarf_size" ]; then
127+
_sentry_dsym_log "Main app dSYM DWARF binary is empty (still being written): $dsym_file_name (attempt $attempt/$max_attempts)"
128+
return 1
129+
fi
130+
131+
echo "Verified main app dSYM is complete: $dsym_file_name"
132+
return 0
133+
}
134+
135+
# Function to wait for dSYM files to be generated
136+
# This addresses a race condition where the upload script runs before dSYM generation completes
137+
wait_for_dsym_files() {
138+
local max_attempts="${SENTRY_DSYM_WAIT_MAX_ATTEMPTS:-10}"
139+
local wait_interval="${SENTRY_DSYM_WAIT_INTERVAL:-2}"
140+
local attempt=1
141+
local total_wait_time=0
142+
143+
# Check if we should wait for dSYM files
144+
if [ "$SENTRY_DSYM_WAIT_ENABLED" == "false" ]; then
145+
echo "SENTRY_DSYM_WAIT_ENABLED=false, skipping dSYM wait check"
146+
return 0
147+
fi
148+
149+
# Warn if DWARF_DSYM_FILE_NAME is not set - we can't verify the main app dSYM
150+
if [ -z "$DWARF_DSYM_FILE_NAME" ]; then
151+
echo "warning: DWARF_DSYM_FILE_NAME not set, cannot verify main app dSYM specifically"
152+
echo "warning: Will proceed when any dSYM bundle is found"
153+
fi
154+
155+
echo "Checking for dSYM files in: $DWARF_DSYM_FOLDER_PATH"
156+
157+
# Debug information to help diagnose issues
158+
_sentry_dsym_log "DEBUG: DWARF_DSYM_FOLDER_PATH=$DWARF_DSYM_FOLDER_PATH"
159+
_sentry_dsym_log "DEBUG: DWARF_DSYM_FILE_NAME=$DWARF_DSYM_FILE_NAME"
160+
_sentry_dsym_log "DEBUG: PRODUCT_NAME=$PRODUCT_NAME"
161+
162+
while [ $attempt -le $max_attempts ]; do
163+
if _sentry_check_dsym_ready "$DWARF_DSYM_FOLDER_PATH" "$DWARF_DSYM_FILE_NAME" "$attempt" "$max_attempts"; then
164+
return 0
165+
fi
166+
167+
if [ $attempt -lt $max_attempts ]; then
168+
# Progressive backoff: quick checks first, longer waits later
169+
# Attempts 1-3: 0.5s (total 1.5s)
170+
# Attempts 4-6: 1s (total 3s)
171+
# Attempts 7+: 2s (remaining time)
172+
local current_interval="$wait_interval"
173+
if [ -z "${SENTRY_DSYM_WAIT_INTERVAL}" ]; then
174+
# Only use progressive intervals if user hasn't set custom interval
175+
if [ $attempt -le 3 ]; then
176+
current_interval=0.5
177+
elif [ $attempt -le 6 ]; then
178+
current_interval=1
179+
else
180+
current_interval=2
181+
fi
182+
fi
183+
184+
echo "Waiting ${current_interval}s for dSYM generation to complete..."
185+
sleep $current_interval
186+
total_wait_time=$(awk "BEGIN {print $total_wait_time + $current_interval}")
187+
fi
188+
189+
attempt=$((attempt + 1))
190+
done
191+
192+
# Timeout reached
193+
echo "warning: Timeout waiting for dSYM files after ${total_wait_time}s ($max_attempts attempts)"
194+
echo "warning: This may result in incomplete debug symbol uploads"
195+
echo "warning: To disable this check, set SENTRY_DSYM_WAIT_ENABLED=false"
196+
return 1
197+
}
198+
61199
XCODE_BUILD_CONFIGURATION="${CONFIGURATION}"
62200

63201
if [ "$SENTRY_DISABLE_AUTO_UPLOAD" == true ]; then
@@ -67,6 +205,12 @@ elif [ "$SENTRY_DISABLE_XCODE_DEBUG_UPLOAD" == true ]; then
67205
elif echo "$XCODE_BUILD_CONFIGURATION" | grep -iq "debug"; then # case insensitive check for "debug"
68206
echo "Skipping debug files upload for *Debug* configuration"
69207
else
208+
# Wait for dSYM files to be generated (addresses race condition in EAS builds)
209+
# Don't fail the script if wait times out - we still want to attempt upload
210+
set +e
211+
wait_for_dsym_files
212+
set -e
213+
70214
# 'warning:' triggers a warning in Xcode, 'error:' triggers an error
71215
set +x +e # disable printing commands otherwise we might print `error:` by accident and allow continuing on error
72216
SENTRY_UPLOAD_COMMAND_OUTPUT=$(/bin/sh -c "\"$LOCAL_NODE_BINARY\" $UPLOAD_DEBUG_FILES" 2>&1)

packages/core/test/expo-plugin/modifyXcodeProject.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,110 @@ describe('Configures iOS native project correctly', () => {
7777
expect(warnOnce).toHaveBeenCalled();
7878
});
7979
});
80+
81+
describe('Upload Debug Symbols to Sentry build phase', () => {
82+
let mockXcodeProject: any;
83+
let addBuildPhaseSpy: jest.Mock;
84+
const expectedShellScript =
85+
"/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
86+
87+
const getOptions = () => {
88+
const callArgs = addBuildPhaseSpy.mock.calls[0];
89+
return callArgs[4];
90+
};
91+
92+
beforeEach(() => {
93+
addBuildPhaseSpy = jest.fn();
94+
mockXcodeProject = {
95+
pbxItemByComment: jest.fn().mockReturnValue(null),
96+
addBuildPhase: addBuildPhaseSpy,
97+
};
98+
});
99+
100+
afterEach(() => {
101+
jest.clearAllMocks();
102+
});
103+
104+
it('creates Upload Debug Symbols build phase with correct shell script', () => {
105+
mockXcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
106+
shellPath: '/bin/sh',
107+
shellScript: expectedShellScript,
108+
});
109+
110+
expect(addBuildPhaseSpy).toHaveBeenCalledWith(
111+
[],
112+
'PBXShellScriptBuildPhase',
113+
'Upload Debug Symbols to Sentry',
114+
null,
115+
{
116+
shellPath: '/bin/sh',
117+
shellScript: expectedShellScript,
118+
},
119+
);
120+
});
121+
122+
it('does not include inputPaths to avoid circular dependency', () => {
123+
// We don't use inputPaths because they cause circular dependency errors in Xcode 15+
124+
// (see issue #5641). Instead, the bash script waits for dSYM files to be generated.
125+
mockXcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
126+
shellPath: '/bin/sh',
127+
shellScript: expectedShellScript,
128+
});
129+
130+
const options = getOptions();
131+
132+
expect(options.inputPaths).toBeUndefined();
133+
});
134+
135+
it('skips creating build phase if it already exists', () => {
136+
mockXcodeProject.pbxItemByComment = jest.fn().mockReturnValue({
137+
shellScript: 'existing',
138+
});
139+
140+
expect(addBuildPhaseSpy).not.toHaveBeenCalled();
141+
});
142+
143+
describe('Race condition handling', () => {
144+
it('documents why we do not use inputPaths', () => {
145+
// This test documents the decision NOT to use inputPaths.
146+
//
147+
// ISSUE #5288: Race condition where upload script runs before dSYM generation completes
148+
// ISSUE #5641: inputPaths cause circular dependency errors in Xcode 15+
149+
//
150+
// We attempted to fix #5288 by adding inputPaths to declare dependency on dSYM files:
151+
// inputPaths: [
152+
// '"$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Resources/DWARF/$(PRODUCT_NAME)"',
153+
// '"$(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)"',
154+
// ]
155+
//
156+
// However, this caused Xcode 15+ to fail with:
157+
// "Cycle inside X; building could produce unreliable results"
158+
//
159+
// The cycle occurs because:
160+
// 1. The target produces the dSYM as an output during linking
161+
// 2. The "Upload Debug Symbols" build phase (part of the same target) declares the dSYM as an input
162+
// 3. Xcode detects: target depends on its own output = CYCLE
163+
//
164+
// SOLUTION: Instead of using inputPaths, the bash script (sentry-xcode-debug-files.sh)
165+
// now waits for dSYM files to exist before uploading. This avoids the circular dependency
166+
// while still handling the race condition.
167+
//
168+
// See:
169+
// - https://github.com/getsentry/sentry-react-native/issues/5288
170+
// - https://github.com/getsentry/sentry-react-native/issues/5641
171+
// - https://developer.apple.com/forums/thread/730974
172+
173+
mockXcodeProject.addBuildPhase([], 'PBXShellScriptBuildPhase', 'Upload Debug Symbols to Sentry', null, {
174+
shellPath: '/bin/sh',
175+
shellScript: expectedShellScript,
176+
});
177+
178+
const options = getOptions();
179+
180+
// Verify that inputPaths are NOT used
181+
expect(options.inputPaths).toBeUndefined();
182+
expect(options.shellPath).toBe('/bin/sh');
183+
expect(options.shellScript).toBe(expectedShellScript);
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)