Fix JNI stale local ref in OfflineStorage_Room record iteration loops - #1417
Merged
Conversation
GetAndReserveRecords, GetRecords, and ReleaseRecords each use a pushLocalFrame/popLocalFrame scope per record. The jclass obtained from GetObjectClass on the first iteration was stored as a plain local reference; after popLocalFrame returns, that reference is freed by ART. On subsequent iterations the C++ pointer is non-null, so the if(!record_class) guard is skipped, but the jclass* now points into a freed local frame. ART's JNI reference validity checker detects this and calls JniAbort, producing SIGABRT. Fix: promote the jclass to a global reference via NewGlobalRef immediately after GetObjectClass so it survives across popLocalFrame calls, and delete it with DeleteGlobalRef after the loop. Affected methods: - GetAndReserveRecords: record_class - ReleaseRecords: bt_class - GetRecords: record_class The bug was introduced in commit 873b562 (Jan 2021) when per-record pushLocalFrame/popLocalFrame scoping was added. See GitHub issue #1227. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The initial fix (previous commit) correctly promoted jclass local refs to global refs to survive pushLocalFrame/popLocalFrame, but placed DeleteGlobalRef only after the loop in normal flow. Three paths leaked: 1. ThrowLogic throws std::logic_error — NOT caught by catch(runtime_error) 2. ThrowRuntime throws std::runtime_error — catch block never called Delete 3. consumer() throwing in GetAndReserveRecords also bypassed the delete Fix: replace post-loop DeleteGlobalRef with a RAII guard struct whose destructor calls DeleteGlobalRef unconditionally. Applied to all three methods: GetAndReserveRecords, ReleaseRecords, and GetRecords. Also: - Initialize all jfieldID variables to nullptr (prevents use of indeterminate values if ThrowLogic fires mid field-ID setup) - Check NewGlobalRef return value; throw std::runtime_error on null (caught by existing catch block) Regression test improvements: - GetRecords section: add order-independent set-based blob[0] check to verify field IDs cached on iteration 0 are valid on iterations 1+ - ReleaseRecords section: cycle GetAndReserveRecords + ReleaseRecords (incrementRetryCount=true) GetMaximumRetryCount()+1 times so the Room impl actually drops records and returns a non-empty byTenant array, exercising the bt_class loop. Without this, bt_class path was never entered. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OfflineStorage_Room.cpp: - Move GlobalRefGuard struct into the anonymous namespace at the top of the file, replacing three identical inline local struct definitions. Eliminates duplication and makes a single authoritative definition. - Change guard member from jclass& ref to jclass* ref_ptr for cleaner pointer semantics (more idiomatic RAII ownership pattern). Tests: - GetAndReserveRecords blob check: switch from index-based to set-based comparison since Memory storage returns records in LIFO order, not insertion order. - GetRecords check: guard with if(!Memory) because Memory::GetRecords delegates to GetAndReserveRecords internally and returns nothing when records are already reserved. Build fixes (macOS Apple Silicon): - lib/CMakeLists.txt: use find_library to set IMPORTED_LOCATION on the sqlite3 IMPORTED GLOBAL target; without this cmake fails to generate on Apple Silicon where homebrew lives under /opt/homebrew. - tests/unittests/CMakeLists.txt: add /opt/homebrew/opt/sqlite/lib path to the sqlite3 library lookup chain. - lib/modules: update submodule to fix -Werror,-Wreorder-ctor build failure in Sanitizer.cpp (initializer list order mismatch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hanselip
force-pushed
the
fix/offline-storage-room-jni-stale-local-ref
branch
from
March 24, 2026 16:37
7461d7c to
65ec904
Compare
lalitb
reviewed
Apr 9, 2026
| PATHS /usr/local/lib /usr/local/opt/sqlite/lib /opt/homebrew/opt/sqlite/lib | ||
| NO_DEFAULT_PATH) | ||
| if(SQLITE3_STATIC_LIB) | ||
| set_target_properties(sqlite3 PROPERTIES IMPORTED_LOCATION ${SQLITE3_STATIC_LIB}) |
Contributor
There was a problem hiding this comment.
nit - This looks like a dead step: the static link still hardcodes libsqlite3.a instead of using the sqlite3 CMake target/path that was just set up. But should be harmless to let it be :)
lalitb
approved these changes
Apr 9, 2026
lalitb
left a comment
Contributor
There was a problem hiding this comment.
LGTM. Thanks, the CI is breaking not related to the PR.
bmehta001
added a commit
that referenced
this pull request
Jun 16, 2026
… m_room/element) Defensive follow-up to the #1227 family of Android Room crashes ("java_object == null in call to GetObjectClass" under GetAndReserveRecords). #1417 fixed the stale-local-ref root cause but left two null paths unguarded, which can still hard-abort the host process: * m_room is null when the Room DB failed to open or was torn down (the destructor already guards with `if (s_vm && m_room)`, but ~10 other methods dereferenced it unconditionally). Add `if (!m_room) return <fail>;` guards to DeleteRecords(x2), GetAndReserveRecords, ReleaseRecords, StoreRecords, DeleteSetting, StoreSetting, GetSizeInternal, GetRecordCount, ResizeDbInternal, and GetRecords. GetSetting already had this guard. * a null element in the getAndReserve/releaseRecords result array (observed to be androidx.room-version sensitive) was passed to GetObjectClass. Guard both loops: GetAndReserveRecords pops the frame and stops (the existing index < limit path releases the rest for retry); ReleaseRecords skips it. Turns a process-killing JNI abort into graceful degradation. No functional change on the healthy path. Compiles on Android only (JNI/Room) -- validated by CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
bmehta001
added a commit
that referenced
this pull request
Jun 23, 2026
* Update EventPropertiesDecorator.hpp
Scrub IP addresses by default.
* Gate IP scrubbing behind CFG_BOOL_ENABLE_IP_SCRUBBING (default on)
The initial change set RECORD_FLAGS_EVENTTAG_SCRUB_IP unconditionally for
every event, forcing IP scrubbing on all SDK consumers in direct-upload
mode -- a breaking change for apps that need client IP (e.g. geo-location).
Make scrubbing the default but opt-out: the decorator sets the SCRUB_IP
record flag unless CFG_BOOL_ENABLE_IP_SCRUBBING is explicitly set to false.
record.flags is forwarded on the cross-platform/direct-upload path, so this
redacts client IP at the collector without relying on ext.metadata privacy
tags.
- ILogConfiguration.hpp: add CFG_BOOL_ENABLE_IP_SCRUBBING config key
- EventPropertiesDecorator.hpp: gate SCRUB_IP flag behind the config
- EventPropertiesDecoratorTests.cpp: add default-on, opt-out, explicit-enable
tests with a per-instance ConfigurableLogManager helper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Cover SDK stats events and add wrapper-parity config keys for IP scrubbing
- Statistics.cpp: SDK statistics/metastats events bypass EventPropertiesDecorator,
so apply the same collector-side client-IP scrub (gated by
CFG_BOOL_ENABLE_IP_SCRUBBING, on by default) to those records too. Closes the
gap identified in PR review.
- LogConfigurationKey.java + ODWLogConfiguration.{h,mm}: expose
CFG_BOOL_ENABLE_IP_SCRUBBING ('enableIpScrubbing') to the Android (Java) and
Apple (Obj-C) wrappers for API parity.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Clarify CFG_BOOL_ENABLE_IP_SCRUBBING docstring (Copilot round 1)
The doc implied the setting only applies in direct-upload mode, but the scrub
flag is set for all events and modes. Clarify that the flag is honored by the
OneCollector direct-upload path while UTC mode applies its own client-privacy
handling. No behavior change -- the flag is intentionally mode-agnostic and is
ignored by the UTC pipeline.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Address Copilot round 2 on #1161: self-contained header + default-config docs
- EventPropertiesDecorator.hpp: include ILogManager.hpp so the header is
self-contained for the ILogManager (m_owner) and ILogConfiguration types /
CFG_BOOL_ENABLE_IP_SCRUBBING used inline, instead of relying on the includer.
- Clarify CFG_BOOL_ENABLE_IP_SCRUBBING docs (C++ / Java / Obj-C): scrubbing is
applied unless explicitly set to false (on by default) and the key is not
present in the default configuration, so GetDefaultConfiguration() does not
surface it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Address Copilot round 3 on #1161: extract on-wire record flags to a shared header
Move the RECORD_FLAGS_EVENTTAG_* on-wire bits out of EventPropertiesDecorator.hpp
into a dedicated decorators/RecordFlagConstants.hpp, exposed as static constexpr
std::int64_t in the MAT namespace (no longer #define macros). This lets the stats
pipeline reference RECORD_FLAGS_EVENTTAG_SCRUB_IP via the small shared header
instead of pulling in the full decorator header, and avoids macro pollution.
- New: lib/decorators/RecordFlagConstants.hpp
- EventPropertiesDecorator.hpp: include the shared header; drop the macros
- Statistics.cpp: include the shared header instead of EventPropertiesDecorator.hpp
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Harden OfflineStorage_Room against null JNI objects (no-crash on null m_room/element)
Defensive follow-up to the #1227 family of Android Room crashes ("java_object ==
null in call to GetObjectClass" under GetAndReserveRecords). #1417 fixed the
stale-local-ref root cause but left two null paths unguarded, which can still
hard-abort the host process:
* m_room is null when the Room DB failed to open or was torn down (the
destructor already guards with `if (s_vm && m_room)`, but ~10 other methods
dereferenced it unconditionally). Add `if (!m_room) return <fail>;` guards to
DeleteRecords(x2), GetAndReserveRecords, ReleaseRecords, StoreRecords,
DeleteSetting, StoreSetting, GetSizeInternal, GetRecordCount,
ResizeDbInternal, and GetRecords. GetSetting already had this guard.
* a null element in the getAndReserve/releaseRecords result array (observed to
be androidx.room-version sensitive) was passed to GetObjectClass. Guard both
loops: GetAndReserveRecords pops the frame and stops (the existing
index < limit path releases the rest for retry); ReleaseRecords skips it.
Turns a process-killing JNI abort into graceful degradation. No functional change
on the healthy path. Compiles on Android only (JNI/Room) -- validated by CI.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* docs: note the androidx.room version the SDK is built against (#1227 follow-up)
cpp-start-android.md covered Room setup but said nothing about the Room version.
The GetAndReserveRecords native crash (#1227, Room-version-sensitive) and the
null-guard hardening in this PR make this worth documenting: the maesdk AAR
brings androidx.room transitively (pinned in maesdk/build.gradle, currently
2.8.4); since Gradle resolves one Room version app-wide, consumers should not
force a version below what the SDK is built against and should prefer aligning
on the bundled (or a compatible newer) version. Points at build.gradle as the
source of truth so the doc doesn't drift on future bumps.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* Room: guard null JNIEnv in 5 JNI methods; skip releaseUnconsumed on null element
Address the latest Copilot review (6 comments) on
lib/offline/OfflineStorage_Room.cpp.
ConnectedEnv null-env guards (5): DeleteByToken, ReleaseRecords, DeleteSetting,
StoreSetting and GetRecords created ConnectedEnv env(s_vm) and dereferenced
env->... behind only an if(!m_room) guard. ConnectedEnv::operator! can report a
null JNIEnv (null s_vm / thread-attach failure), and sibling methods already
guard with if(!env); added the matching early return (void/false/records) so a
null env no longer crashes.
releaseUnconsumed on null element: the null-array-element path broke out and
fell through to releaseUnconsumed(selected, index). The Java
StorageRecordDao.releaseUnconsumed (pre-existing since 2020, commit c81d46a)
reads selected[0..unconsumed-1] ignoring the offset, so on the null path it
could index the null element and throw, or release the wrong rows. A
sawNullElement flag now skips releaseUnconsumed on that path; the reserved
records expire and are retried (no data loss), and the normal end-early path is
unchanged.
Validated: NDK aarch64-linux-android23 -fsyntax-only clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Lalit Kumar Bhasin <labhas@microsoft.com>
Co-authored-by: bmehta001 <bmehta001@users.noreply.github.com>
Co-authored-by: Bhagirath Mehta <bhamehta@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #1227 — JNI abort (SIGABRT) occurring on Android when
OfflineStorage_Roomiterates over multiple records. The bug was introduced in commit873b562(Jan 2021) when per-recordPushLocalFrame/PopLocalFramecalls were added.Root cause:
GetObjectClass()returns a local JNI reference. Local refs are freed byPopLocalFrame(). The code cached the returnedjclassin an outer variable and reused it on iterations 1+, by which pointPopLocalFrame()had already freed it. ART's JNI reference checker detects the stale ref passed toGetFieldID/GetMethodIDand callsJniAbort()→abort()→ SIGABRT.Affected methods (all fixed):
GetAndReserveRecords—record_classcached across iterationsReleaseRecords—bt_classcached across iterationsGetRecords—record_classcached across iterationsFix
Promote the
jclasslocal ref to a JNI global ref viaNewGlobalRef()on the first iteration. Global refs are not freed byPopLocalFrame()and remain valid until explicitly deleted withDeleteGlobalRef().A
GlobalRefGuardRAII struct (defined once in the anonymous namespace) handles cleanup on all exit paths — normal completion,break,std::logic_error(thrown byThrowLogic),std::runtime_error(thrown byThrowRuntime), andconsumer()exceptions — which a post-loopDeleteGlobalRefwould miss.Additional hardening:
NewGlobalRefnull check (throwsstd::runtime_erroron OOM, caught by existing handler)jfieldIDvariables initialized tonullptrto prevent indeterminate values ifThrowLogicfires mid field-ID setupTest
Added
MultiRecordIterationFieldIdValidityregression test that:GetAndReserveRecords(exercises "reuse jclass on iteration 2+" path)GetRecordsreturns all 3 with correct field valuesGetAndReserveRecords+ReleaseRecords(incrementRetryCount=true)GetMaximumRetryCount()+1times to exhaust retry counts, causing the Room impl to return a non-emptybyTenantarray — exercising thebt_classiteration path inReleaseRecordswith 3 loop iterationsAll 83 non-Android unit tests pass (Room tests require Android device/emulator).
Note
Several other methods (
DeleteRecords,StoreSetting,GetSetting,StoreRecords) callpushLocalFramebut rely on theConnectedEnvdestructor to drain frames rather than explicitpopLocalFrame. This is safe (the destructor always drains the stack) but inconsistent — left as a candidate follow-up.