firebase / firebase/firebase-ios-sdk

Firebase Analytics: SQLite failure when two unsandboxed processes initialize concurrently

Open
#16,208 3 comments 0 reactions 0 assignees View on GitHub
api: analytics
Dominant language
C++
Stars
6.7k
Forks
1.8k
Avg merge
2d 14h
Merged PRs (30d)
72

Description

### Description

### Environment

- Firebase iOS SDK: 11.4.0 (GoogleAppMeasurement)
- Platform: macOS 13+
- Affected components: `FirebaseAnalytics` / `GoogleAppMeasurement`, `FirebaseRemoteConfig`

---

### Description

When two or more unsandboxed macOS processes (daemons, XPC services, or apps without
`com.apple.security.app-sandbox`) both call `FirebaseApp.configure()` while running as the
same OS user, they share the same SQLite database files. This causes two distinct problems:

**Problem 1: SQLite lock contention causes Analytics to be permanently disabled**

Both processes share a single file (confirmed by `[I-ACS012003]` in production logs):
```
/var/root/Library/Application Support/Google/Measurement/google-app-measurement.sql
```
Firebase opens it with `BEGIN EXCLUSIVE TRANSACTION` and no `busy_timeout` (confirmed via
`strings` on the `GoogleAppMeasurement` binary). When both processes hit table creation
concurrently, they lock each other out. Both emit
`[I-ACS023011] Failed to start Analytics. Analytics is disabled`.

Even when both processes successfully initialize Analytics without hitting the table
creation race, they continue to race on every subsequent event write throughout their
lifetimes. Any event logged by either process acquires `BEGIN EXCLUSIVE TRANSACTION` on
the shared file, blocking the other process's concurrent writes and causing event losses.

**Problem 2: App ID collision corrupts measurement state on every co-startup**

The `app_metadata` table contains exactly **one row** with no per-process discriminator.
That row stores a protobuf blob (`remote_config_data`) whose **field 2 is the Firebase
App ID** of whichever process last wrote to it. Inspecting the live production database
directly confirms this:

```sql
SELECT hex(remote_config_data) FROM app_metadata;
-- Decoding protobuf field 2:
-- field 2 (string, 42b): 1:xxxxxxxxxx:ios:xxxxxxxxxxxxxxxx <- App ID of last writer
```

When processes are configured with different App IDs (e.g. two daemons registered as
separate apps in the same Firebase project), every co-startup produces this sequence:

1. Process A writes its App ID into `app_metadata.remote_config_data`
2. Process B starts, reads `app_metadata`, finds a different App ID
3. Process B emits `[I-ACS025020] Google App ID has been changed. Your data may be lost.`
followed by `[I-ACS023171] Resetting App Measurement data`
4. Process B overwrites the row with its own App ID, destroying Process A's queued events,
user properties, session state, and downloaded Remote Config measurement settings

This cycle repeats on every co-startup regardless of startup order.

**Note on Remote Config:** The `RemoteConfig.sqlite3` database uses `bundle_identifier` as
a column in all tables, so multiple processes already coexist in the same file without
data collision. Remote Config's only problem is lock contention (Problem 1 only), not App
ID collision. The same `NSApplicationSupportDirectory + NSUserDomainMask` path is
confirmed for Remote Config as well:
```
/var/root/Library/Application Support/Google/RemoteConfig/RemoteConfig.sqlite3
```

**Note on sandboxed processes:** On iOS and sandboxed macOS apps,
`NSApplicationSupportDirectory + NSUserDomainMask` is redirected by the OS into each
app's private container. No two sandboxed apps share these paths. The issue affects only
unsandboxed processes.

---

### Steps to Reproduce

Run two unsandboxed macOS processes configured with different Firebase App IDs that both
call `FirebaseApp.configure()` at the same time while sharing the same OS user context
(e.g. two launchd daemons running as root).

---

### Actual Behavior: Production Log

The following is a verbatim excerpt from a production log of two root daemons starting
14ms apart. Process names are redacted.

```
15:32:37.632 DaemonA: [I-COR000003] The default Firebase app has not yet been configured.
15:32:37.639 DaemonA: [I-ACS023007] Analytics v.11.4.0 started
15:32:37.646 DaemonB: [I-COR000003] The default Firebase app has not yet been configured.
15:32:37.650 DaemonA: [I-ACS023171] Resetting App Measurement data
15:32:37.656 DaemonB: [I-ACS025020] Analytics requires Google App ID from GoogleService-Info.plist.
Your data may be lost. Google App ID has been changed.
Original, new ID: 1:xxx:ios:aaa, 1:xxx:ios:bbb
15:32:37.910 DaemonB: [I-ACS023007] Analytics v.11.4.0 started

15:32:37.924 DaemonA: [I-ACS012047] Failed to ensure table. Table, error: event_filters
sqlite code 10 "disk I/O error"
15:32:38.002 DaemonA: [I-ACS012002] Failed to create or validate required database tables (Init)
15:32:38.150 DaemonB: [I-ACS012047] Failed to ensure table. Table, error: event_filters
sqlite code 8 "attempt to write a readonly database"
15:32:38.166 DaemonA: [I-ACS012047] Failed to ensure table. Table, error: event_filters
sqlite code 8 "attempt to write a readonly database"
15:32:38.224 DaemonB: [I-ACS012002] Failed to create or validate required database tables (Init)
15:32:38.226 DaemonA: [I-ACS012002] Failed to create or validate required database tables (Init)
15:32:38.292 DaemonA: [I-ACS023011] Failed to start Analytics. Analytics is disabled
15:32:38.292 DaemonA: [I-ACS012047] sqlite code 8 "attempt to write a readonly database"
15:32:38.292 DaemonB: [I-ACS012047] sqlite code 10 "disk I/O error"
15:32:38.312 DaemonA: [I-ACS012002] Failed to create or validate required database tables (Init)
15:32:38.358 DaemonB: [I-ACS012002] Failed to create or validate required database tables (Init)

15:32:38.426 DaemonA: [I-ACS012003] Failed to delete old database file after failed validation.
NSFilePath=/var/root/Library/Application Support/Google/Measurement/google-app-measurement.sql
Error: "google-app-measurement.sql" couldn't be removed.
NSUnderlyingError: No such file or directory

15:32:38.519 DaemonB: [I-ACS012047] sqlite code 10 "disk I/O error"
15:32:38.544 DaemonA: [I-ACS012047] sqlite code 5 "database is locked"
15:32:38.571 DaemonB: [I-ACS012002] Failed to create or validate required database tables (Init)
15:32:38.573 DaemonA: [I-ACS012002] Failed to create or validate required database tables (Init)

15:32:38.574 DaemonB: [I-ACS012003] Failed to delete old database file after failed validation.
NSFilePath=/var/root/Library/Application Support/Google/Measurement/google-app-measurement.sql
Error: "google-app-measurement.sql" couldn't be removed.

15:32:38.292 DaemonA: [I-ACS023011] Failed to start Analytics. Analytics is disabled
15:32:39.025 DaemonB: [I-ACS011005] Cannot query for conditional user properties. Analytics is disabled
```

**Result: both processes have Analytics disabled for the entire session.**

#### Contrast: single process restart

When DaemonA is restarted alone with DaemonB not running, Analytics initializes
successfully with no SQLite errors:

```
53:29.929 DaemonA: [I-ACS023007] Analytics v.11.4.0 started
53:29.945 DaemonA: [I-ACS023171] Resetting App Measurement data
53:30.398 DaemonA: [I-ACS800023] No pending snapshot to activate. SDK name: app_measurement
53:30.412 DaemonA: [I-ACS023012] Analytics collection enabled
```

No `I-ACS012047`, no `I-ACS012002`, no `I-ACS023011`. The failure is exclusively a
concurrent-initialization problem.

---

### Proposed Fix

The two problems require different fixes:

**Problem 2 (App ID collision): primary ask: `databaseDirectory` on `FirebaseOptions`**

The only fix that eliminates the App ID collision is giving each process its own database
file. A new property on `FirebaseOptions` would address this:

```swift
var databaseDirectory: URL? // nil = current behavior (NSApplicationSupportDirectory)
```

When set, `GoogleAppMeasurement` would use this URL as the base path for its SQLite
database. Each unsandboxed process can then be directed to its own path (e.g. based on
bundle identifier), ensuring the `app_metadata` row is never shared and App IDs never
collide.

**Problem 1 (SQLite lock contention): secondary: WAL mode and busy timeout**

For the broader case of unsandboxed processes that share a database, two `PRAGMA`
statements at database open time would prevent the hard `Analytics is disabled` failure.
These are required in both `APMDatabase` (Analytics) and `RCNConfigDBManager`
(Remote Config), as both use the same path resolution and default journal mode:

```objc
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
sqlite3_exec(db, "PRAGMA busy_timeout=5000;", NULL, NULL, NULL);
```

Note: these PRAGMAs address Problem 1 only. For processes with different App IDs sharing
the same file, `app_metadata` corruption and the reset cycle (Problem 2) would continue
even with WAL and a busy timeout. `databaseDirectory` is required to close that loop.

---

### Impact

- Any two unsandboxed macOS processes that both call `FirebaseApp.configure()` while
running as the same OS user
- Root daemons (UID 0) are particularly affected because all such processes share
`/var/root/Library/Application Support/`
- Hard failure: `Analytics is disabled` for the entire session when lock contention
occurs at startup
- Soft corruption: `app_metadata` overwritten on every co-startup when processes use
different App IDs, losing queued events, user properties, session state, and downloaded
Remote Config measurement settings

#### Secondary impact: Remote Config user-property targeting fails

When Analytics is disabled or user properties are reset, any Remote Config fetch
conditions that target those user properties evaluate as unmatched. The corresponding
feature flag values are not returned to the affected process. Feature rollouts and A/B
experiments that use user-property targeting silently fail to reach devices whenever two
unsandboxed processes start concurrently.

---

### References

- [SQLite WAL](https://www.sqlite.org/wal.html)
- [SQLite busy_timeout](https://www.sqlite.org/pragma.html#pragma_busy_timeout)

### Reproducing the issue

See inline in the description

### Firebase SDK Version

11.4.0

### Xcode Version

26.1

### Installation Method

CocoaPods

### Firebase Product(s)

Analytics, Remote Config

### Targeted Platforms

iOS, macOS

### Relevant Log Output

```shell
See inline in the description
```

### If using Swift Package Manager, the project's Package.resolved

Expand Package.resolved snippet

```json

Replace this line with the contents of your Package.resolved.

```

### If using CocoaPods, the project's Podfile.lock

Expand Podfile.lock snippet

```yml

Replace this line with the contents of your Podfile.lock!

```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.