Skip to content

fix: [SDK-4899] lock OSSubscriptionModel state to stop encode crashes#1694

Open
nan-li wants to merge 1 commit into
mainfrom
nan/sdk-4899
Open

fix: [SDK-4899] lock OSSubscriptionModel state to stop encode crashes#1694
nan-li wants to merge 1 commit into
mainfrom
nan/sdk-4899

Conversation

@nan-li

@nan-li nan-li commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description

One Line Summary

Lock-protect OSSubscriptionModel stored state so concurrent encode can no longer crash.

Details

Motivation

Fixes production EXC_BAD_ACCESS crashes in OSSubscriptionModel.encode(with:) when the model is archived on a background queue (operation repo delta queue / subscription executor request caches) while another thread mutates its String-typed properties. Same crash as GitHub #1588.

Scope

  • OSSubscriptionModel only: stored properties move into a value-type State guarded by one NSLock, held only for raw reads/writes (never across change events, observers, or UserDefaults).
  • encode(with:), jsonRepresentation(), and currentPushSubscriptionState read one consistent snapshot.
  • Existing setter side effects are preserved.

Thanks @chunhuidu-dev for #1685 that informed this fix.

Testing

Unit testing

Added SubscriptionModelConcurrencyTests:

  • Deterministic snapshot test: pause mid-encode, mutate the model, assert the archive is consistent
  • Concurrent encode + mutation hammer
  • notificationTypes pin-to--2 while disabled without firing a change event, such as when already opted out and something tries to write a non-negative-two value (e.g. a permission refresh with 15). Disabled is supposed to supersede the real notification type. While disabled, don’t let a stray write of 15 fire a change.

Manual testing

Not personally reproduced on device (customer crash; not reliably reproducible). Covered by the unit tests above.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

Made with Cursor

OSSubscriptionModel is archived on background queues (operation repo delta
queue, executor pending-request caches, model store persistence) while other
threads mutate its String-typed properties (APNs token, permission changes,
server hydration). The unsynchronized reads crashed in production with
EXC_BAD_ACCESS in objc_retain during encode(with:) (GitHub #1588).

Move the stored properties into a value-type State guarded by a single NSLock
held only for raw reads/writes - never while firing change events, notifying
observers, or writing UserDefaults - so the re-entrant archive path and
callouts into app code cannot deadlock. encode(with:), jsonRepresentation(),
and currentPushSubscriptionState now read one consistent snapshot. All
existing setter side-effect logic is preserved, including notificationTypes
pinning to -2 while disabled without firing a change event.

Co-Authored-By: chunhuidu-dev <270835659+chunhuidu-dev@users.noreply.github.com>
@nan-li

nan-li commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

@claude review once

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found, but this is a non-trivial concurrency refactor of a core model class (OSSubscriptionModel) that's used in REST subscription requests and fixes a production crash, so I'd like a human to weigh in before merge.

What was reviewed:

  • Verified the new NSLock-guarded State struct is only ever held for raw reads/writes, never across set(property:)/change-notifier callouts, ruling out re-entrant deadlock on the setter -> model store save -> encode path.
  • Checked encode(with:), jsonRepresentation(), and currentPushSubscriptionState all take a single snapshot() so they no longer read torn state.
  • Examined the notificationTypes pin-to--2-while-disabled path (flagged by the bug hunting system) — confirmed it's a real, transient value visible during concurrent encode/jsonRepresentation, but the lock ensures each individual read is internally consistent, not corrupted.
Extended reasoning...

This PR converts all stored properties of OSSubscriptionModel into a private lock-guarded State struct to fix a production EXC_BAD_ACCESS crash in encode(with:) (issue #1588) caused by concurrent mutation during archiving. It touches getters/setters for every property (address, subscriptionId, notificationTypes, reachable, isDisabled, testType, deviceOs, sdk, deviceModel, appVersion, netType), the two initializers, encode/decode, jsonRepresentation, and currentPushSubscriptionState. Test coverage is good: a deterministic pause-mid-encode snapshot test, a 1000-iteration concurrent hammer test, and a targeted test for the notificationTypes pin-to-2 behavior.

No injection/auth/crypto surface is introduced — this is purely internal state synchronization for a model used to build REST request bodies (subscription create/update). The main risk class here is concurrency correctness (deadlock or missed synchronization) rather than a classic security vulnerability.

I traced the lock usage carefully: every setter releases stateLock before calling self.set(property:...) or firing change/observer callouts, so the documented invariant (never hold the lock across notifications or UserDefaults writes) holds, and I did not find a path where the lock is re-acquired while already held on the same thread. I also compared the new notificationTypes setter against the original didSet-based implementation; the new version no longer fires a "notificationTypes" change event on the corrective pin-to--2 path (previously a re-entrant didSet callback did fire one), but since the value being corrected to was already the pinned state, this looks like an intentional and harmless simplification rather than a regression — the accompanying test explicitly asserts no change event fires here.

Given the production-crash motivation, the breadth of the refactor across a widely-used model, and the general principle that concurrency-lock refactors in core state classes benefit from a second set of human eyes, I'm deferring rather than approving outright, even though I did not find any concrete bugs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant