Discovering iOS memory leaks III: Automating with Github Action
Context
In the first two parts of this series, Discovering iOS Memory Leaks, we explored the leaks
command-line tool and successfully contributed to fixing a memory leak in the Firefox iOS repository.
- Discovering iOS memory leaks: A case study with Firefox app
- Discovering iOS memory leaks: A case study with Firefox app II
While that addressed the issue, there’s no assurance that new leaks haven’t surfaced in more recent versions. To maintain confidence in any codebase, we need a solution that sets a baseline and detects leaks proactively before release. This can be solved by automating leak detection as part of our CI pipeline, which we'll achieve through GitHub Actions in this post.
Github action would detect any memory leaks in the flow and would be failing the job, let's discuss the different stages of this Github Action.
Steps in Github Action
The Github Action is divided into the following parts:
- Setting up environment
- Building and Installing the app
- Launching App and running the maestro test.
- Running leaks command
- Upload leak report as artifacts
Setting up environment
We use macOS-13 runner. We set up this runner's environment to run the maestro test, build the code, and use a simulator. Here are the steps we perform:
- Checkout repository code in the runner
- Select xcode version 15.2
- Install maestro CLI
- Boot a simulator, we use the iPhone 14 and iOS 17.2. Here is the script we use to boot the simulator: boot_simulator.sh.
- Ensure the simulator is booted before we install the app.
- name: Checkout
uses: actions/checkout@v4.1.7
- name: Select Xcode
run: sudo xcode-select -switch /Applications/Xcode_15.2.0.app && /usr/bin/xcodebuild -version
- name: Install Maestro
run: |
curl -Ls --retry 3 --retry-all-errors "https://get.maestro.mobile.dev"| bash
echo "${HOME}/.maestro/bin" >> $GITHUB_PATH
- name: Print Maestro version
run: maestro --version
- name: Check xcodebuild
run: xcodebuild -version
- name: List xcode runtimes
run: xcrun simctl list runtimes
- name: Set permission to scripts
run: chmod +x .github/scripts/boot_simulator.sh
- name: Boot Simulator
run: ./.github/scripts/boot_simulator.sh
- name: Check Simulator Status
run: xcrun simctl list
Build and Install Firefox iOS
Now we will be building the Firefox Fennec scheme and install on the booted simulator.
- name: Build Fennec Client
run: |
./checkout.sh
./bootstrap.sh --force
xcodebuild \
build-for-testing \
-scheme Fennec \
-project ./firefox-ios/Client.xcodeproj \
-destination "generic/platform=iOS Simulator" \
-derivedDataPath ./build \
ARCHS="x86_64"
- name: Install App
run: |
xcrun simctl install booted ./build/Build/Products/Fennec_Testing-iphonesimulator/Client.app
Launching the App and Running the Maestro Test
We used MallocStackLogging
flag during launch as we discussed in Part 1 and we wrote a maestro test for the suggestions flow. We will be exporting the process ID in the GitHub environment so that we can invoke leaks
command with it.
- name: Launch App
run: |
export SIMCTL_CHILD_MallocStackLogging=1
OUTPUT=$(xcrun simctl launch booted org.mozilla.ios.Fennec)
PID=$(echo "$OUTPUT" | awk '{print $2}')
echo "PID: $PID"
echo "PID=$PID" >> $GITHUB_ENV
- name: Run test
run: maestro test ./.github/scripts/suggestion_flow.yaml
Running leaks command and Upload leaks report
Now that we have the process ID and assume the maestro test will be successful we can invoke the leaks command and upload the report:
- name: Run leaks command
run: sudo leaks ${{ env.PID }} >> ~/leaks.txt
- name: Upload leaks output
uses: actions/upload-artifact@v4
if: failure()
with:
name: leaks_output
path: ~/leaks.txt
retention-days: 1
Here is the full source code for the GitHub Action: publish_leaks_report.yaml.
Ensuring No New leaks on the flow
We have an automation pipeline for getting leak reports. However, to ensure there are no new leaks, we have to create a baseline for the known leaks so that they only fail when a new leak is introduced.
To exclude the known leaks, leaks
command line offers an --exclude
option for excluding the traces with a symbol. As an example, if you want to exclude the following leak:
STACK OF 1 INSTANCE OF 'ROOT CYCLE: <NSBlockOperation>':
26 dyld start + 1896
25 dyld_sim start_sim + 10
24 org.mozilla.ios.Fennec main + 1014 main.swift:25
23 com.apple.UIKitCore UIApplicationMain + 123
22 com.apple.UIKitCore [UIApplication _run] + 936
21 com.apple.UIKitCore [UIApplication _compellApplicationLaunchToCompleteUnconditionally] + 59
20 com.apple.UIKitCore -[_UISceneLifecycleMultiplexer completeApplicationLaunchWithFBSScene:transitionContext:] + 181
19 com.apple.UIKitCore -[UIApplication _runWithMainScene:transitionContext:completion:] + 1241
18 com.apple.UIKitCore -[UIApplication _callInitializationDelegatesWithActions:forCanvas:payload:fromOriginatingProcess:] + 4253
17 com.apple.UIKitCore -[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:] + 184
16 org.mozilla.ios.Fennec @objc AppDelegate.application(_:willFinishLaunchingWithOptions:) + 183 <compiler-generated>:0
15 org.mozilla.ios.Fennec AppDelegate.application(_:willFinishLaunchingWithOptions:) + 1567 AppDelegate.swift:86
14 org.mozilla.ios.Fennec AppLaunchUtil.setUpPreLaunchDependencies() + 1429 AppLaunchUtil.swift:62
13 org.mozilla.ios.Fennec AppLaunchUtil.initializeExperiments() + 42 AppLaunchUtil.swift:142
12 org.mozilla.ios.Fennec static Experiments.intialize() + 43 Experiments.swift:228
11 org.mozilla.ios.Fennec Experiments.shared.unsafeMutableAddressor + 49 Experiments.swift:137
10 libdispatch.dylib _dispatch_once_callout + 20
9 libdispatch.dylib _dispatch_client_callout + 8
8 org.mozilla.ios.Fennec one-time initialization function for shared + 9 Experiments.swift:137
7 org.mozilla.ios.Fennec closure #1 in variable initialization expression of static Experiments.shared + 1632 Experiments.swift:160
6 org.mozilla.ios.Fennec static Experiments.buildNimbus(dbPath:errorReporter:initialExperiments:isFirstRun:) + 1212 Experiments.swift:213
5 org.mozilla.ios.RustMozillaAppServices NimbusBuilder.build(appInfo:) + 2172
4 org.mozilla.ios.RustMozillaAppServices protocol witness for NimbusStartup.applyLocalExperiments(fileURL:) in conformance Nimbus + 161
3 org.mozilla.ios.RustMozillaAppServices `Nimbus.applyLocalExperiments(getString:) + 93`
2 libobjc.A.dylib _objc_rootAllocWithZone + 47
1 libsystem_malloc.dylib _malloc_type_calloc_outlined + 105
0 libsystem_malloc.dylib _malloc_zone_calloc_instrumented_or_legacy + 204
You can select a unique symbol from the stack trace along with this option, so for this example, it could be: --exclude Nimbus.applyLocalExperiments(getString:)
Conclusion
You can add this GitHub Action to your workflow, either on pull requests or nightly runs, depending on what suits your team best.
Here is an example run from the repository.
There’s still one big question: the action takes a long time to run, which makes the feedback loop pretty slow. It got me thinking—can we run the memory leak detection with unit tests? We’ll dive into this further in Part 4.
Have you tried automating memory leak detection? I’d love to hear your thoughts and learn from your experience. Reach out to me at @droid_singh and let me know.
Photo by Joe Zlomek on Unsplash