[AIT-569] Groundwork for investigation of LocalDevice-related issues#2203
Conversation
WalkthroughThis PR introduces test configuration options for disabling local device functionality in Ably's iOS SDK. It adds a new Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
683e7f9 to
e28e481
Compare
These methods were just one-line calls of our `NSObject` category methods and weren't being used consistently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When attempting to unarchive an object (e.g.
ARTDeviceIdentityTokenDetails) stored in ARTLocalDeviceStorage, in the
case where there was no entry for the key in the storage we were still
passing the nil data to the NSKeyedUnarchiver (violating the contract of
`art_unarchiveFromData:withLogger:`), causing confusing error-level log
messages like:
> (ARTTypes.m:486) ARTDeviceIdentityTokenDetails unarchive failed: Error Domain=NSCocoaErrorDomain Code=4864 \"*** -[NSKeyedUnarchiver _initForReadingFromData:error:throwLegacyExceptions:]: data is NULL\" UserInfo={NSDebugDescription=*** -[NSKeyedUnarchiver _initForReadingFromData:error:throwLegacyExceptions:]: data is NULL}
A storage read miss is part of normal operation of the library (e.g. on
first launch) and should not be considered an error. Change to a
debug-level message that actually explains what's happened:
> ARTDeviceIdentityTokenDetails unarchive skipped: no persisted data for key ARTDeviceIdentityToken
`+art_unarchiveFromData:withLogger:` keeps its existing nonnull `data`
contract, so any caller that does pass nil in future still trips the
louder error log — which at that point would reflect a real bug.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`ARTLocalDeviceStorage` previously logged only errors and one specific
keychain miss ("Device Secret not found"). UserDefaults reads and
writes were silent; successful keychain reads and all keychain writes
(success or not) were silent too. This made it hard to tell, from a
verbose log, whether a given access to persisted device state
happened at all and what its outcome was.
Add a debug log for every access, in both directions and on both
backends:
- UserDefaults: read hit, read miss, write, delete.
- Keychain: read hit, read miss, write success, delete success (the
existing warn/error paths are kept, and now include the `deviceId`
too).
Hits and writes include the value, which is by default rendered as
`(retracted)`. A new test-only logLocalDeviceStorageValues client option
controls this retraction (for debugging).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This test option (i.e. it's not part of the public API but can still be used in Ably-authored test apps by importing Ably.Private) tells a client that it should not touch any of the cross-client shared state that relates to the local device or push activation state machine. The use case here is that I wish to build a test app which: - has two Realtime clients, one called mainClient and one called diagnosticClient, where mainClient is the client under test and diagnosticClient is just being used for submitting diagnostics related to mainClient - I want to submit diagnostics about all of mainClient's accesses and manipulation of the aforementioned shared state and don't want the sequence of such accesses and manipulations to be affected by side-effects caused by diagnosticClient …and thus I wish to set this new disableLocalDevice option on diagnosticClient to avoid such side effects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`+[ARTLocalDevice deviceWithStorage:logger:]` regenerates both the device ID and the device secret whenever either is missing from persisted storage, but until now it gave no indication in the log that this regeneration had occurred, nor which of these scenarios had triggered it. Distinguish and log the two cases: - No stored device ID: emitted at `Debug`. This is the expected state on a fresh install (or after storage is cleared) and, whilst it may also occur as the result of our hypothesised inaccessibility of storage before first unlock (#1109), does not inherently indicate an error. - Stored device ID is present but has no matching secret: emitted at `Error`. The two are written together and read back together, so an orphaned ID strongly suggests that UserDefaults and the keychain have fallen out of sync — a state worth surfacing loudly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
e28e481 to
7c3e73a
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (3)
Source/ARTLocalDeviceStorage.m (2)
22-33: Consider redacting keys as well, or at least normalising the helper name.
valueToLogForValue:only redacts the value; the log lines still emitkey/deviceIdverbatim (e.g. Lines 41, 52, 67, 95). The device ID is arguably not secret, but it is a stable per-install identifier and previously wasn't logged by default. If the intent is that logs are safe to share by default, consider either (a) documenting that keys/device IDs are always logged, or (b) gating thedeviceIdthroughvalueToLogForValue:too when_logValuesisNO. Non-blocking.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Source/ARTLocalDeviceStorage.m` around lines 22 - 33, The current helper valueToLogForValue: only redacts values when _logValues is NO, leaving keys/deviceId exposed; update logging to either (A) pass both keys and values through a single normalized helper (rename valueToLogForValue: to loggableRepresentationForObject: and call it for keys and values wherever logs are emitted) or (B) add a companion method keyToLogForKey: and use it for deviceId/key logging paths so that when _logValues is NO the key/deviceId is also replaced with a redacted token; ensure you update all call sites that log key/deviceId (those emitting key / deviceId) to use the new/renamed helper and keep the existing NSData base64 behavior for values.
46-54: Minor:setObject:forKey:logs "deleted" for any nil write, even when nothing was stored.
NSUserDefaults setObject:nil forKey:is effectivelyremoveObjectForKey:, which is a no-op if the key was absent. The "UserDefaults deleted for key …" log will therefore fire on first-run cleanup paths where nothing existed. Consider reading first and loggingdeletedvsdelete no-op, or softening the wording to"UserDefaults cleared for key %@". Non-blocking.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Source/ARTLocalDeviceStorage.m` around lines 46 - 54, The current setObject:forKey: method always logs "UserDefaults deleted for key …" when value is nil even if the key wasn't present; update -[ARTLocalDeviceStorage setObject:forKey:] to first check NSUserDefaults.standardUserDefaults objectForKey:key (or objectIsPresent) before calling setObject:, then call ARTLogDebug(_logger, ...) with "deleted" only if an existing value was removed and use a softer message like "cleared" or "delete no-op" when there was nothing to remove; keep the existing ARTLogDebug usage and valueToLogForValue: for non-nil writes.Source/ARTThrowingLocalDeviceStorage.m (1)
1-27: Interface conformance is correctly declared. ✓Consider marking the throwing helper
__attribute__((noreturn))(optional).+[NSException raise:format:]is not declarednoreturn, which is why you need the trailingreturn nil;inobjectForKey:/secretForDevice:. You could DRY the four call sites with a small static helper:♻️ Optional refactor
+static void ARTThrowDisableLocalDevice(SEL sel, NSString *detail) __attribute__((noreturn)); +static void ARTThrowDisableLocalDevice(SEL sel, NSString *detail) { + [NSException raise:NSInternalInconsistencyException + format:@"%@ invoked %@ on a client configured with disableLocalDevice", + NSStringFromSelector(sel), detail]; + __builtin_unreachable(); +}Then the
return nil;lines become unnecessary and the exception messages stay consistent.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@Source/ARTThrowingLocalDeviceStorage.m` around lines 1 - 27, The four methods in ARTThrowingLocalDeviceStorage (objectForKey:, setObject:forKey:, secretForDevice:, setSecret:forDevice:) duplicate the NSException raise logic and keep trailing return nil; because +[NSException raise:format:] isn't declared noreturn; add a small static helper (e.g. static void ARTThrowingLocalDeviceStorageRaise(SEL sel, id info) __attribute__((noreturn));) that calls +[NSException raise:format:] with a consistent message and use that helper from each of the four methods, then remove the unnecessary return statements in objectForKey: and secretForDevice:.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Source/ARTLocalDevice.m`:
- Around line 78-85: Update the error log message in the branch where
deviceSecret is missing to accurately reflect that the stored deviceId will be
discarded and replaced: locate the ARTLogError call that references deviceId in
ARTLocalDevice.m and change its wording to something like "Discarding stored
device ID %@ and generating a new ID/secret pair: no secret found
(UserDefaults/Keychain out of sync?)" so it matches the behavior of
generateAndPersistPairOfDeviceIdAndSecret and the PR description.
In `@Test/AblyTests/Test` Utilities/MockDeviceStorage.swift:
- Line 17: Replace the force-unwrap of the nullable result from
art_archive(withLogger:) with a safe unwrap and a test failure on nil: call
art_archive(withLogger:) into a guard/if let, on nil call XCTFail with a clear
message and return, otherwise pass the unwrapped NSData into
simulateOnNextRead(data:for:) for ARTPushActivationCurrentStateKey; do the same
replacement for the identical force-unwrap occurrences in the PushTests tests
where art_archive(withLogger:) is used.
---
Nitpick comments:
In `@Source/ARTLocalDeviceStorage.m`:
- Around line 22-33: The current helper valueToLogForValue: only redacts values
when _logValues is NO, leaving keys/deviceId exposed; update logging to either
(A) pass both keys and values through a single normalized helper (rename
valueToLogForValue: to loggableRepresentationForObject: and call it for keys and
values wherever logs are emitted) or (B) add a companion method keyToLogForKey:
and use it for deviceId/key logging paths so that when _logValues is NO the
key/deviceId is also replaced with a redacted token; ensure you update all call
sites that log key/deviceId (those emitting key / deviceId) to use the
new/renamed helper and keep the existing NSData base64 behavior for values.
- Around line 46-54: The current setObject:forKey: method always logs
"UserDefaults deleted for key …" when value is nil even if the key wasn't
present; update -[ARTLocalDeviceStorage setObject:forKey:] to first check
NSUserDefaults.standardUserDefaults objectForKey:key (or objectIsPresent) before
calling setObject:, then call ARTLogDebug(_logger, ...) with "deleted" only if
an existing value was removed and use a softer message like "cleared" or "delete
no-op" when there was nothing to remove; keep the existing ARTLogDebug usage and
valueToLogForValue: for non-nil writes.
In `@Source/ARTThrowingLocalDeviceStorage.m`:
- Around line 1-27: The four methods in ARTThrowingLocalDeviceStorage
(objectForKey:, setObject:forKey:, secretForDevice:, setSecret:forDevice:)
duplicate the NSException raise logic and keep trailing return nil; because
+[NSException raise:format:] isn't declared noreturn; add a small static helper
(e.g. static void ARTThrowingLocalDeviceStorageRaise(SEL sel, id info)
__attribute__((noreturn));) that calls +[NSException raise:format:] with a
consistent message and use that helper from each of the four methods, then
remove the unnecessary return statements in objectForKey: and secretForDevice:.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 915b67ae-dcf0-40f3-8c4d-9b74b6803c1a
📒 Files selected for processing (23)
Ably.xcodeproj/project.pbxprojSource/ARTAuth.mSource/ARTDeviceIdentityTokenDetails.mSource/ARTLocalDevice.mSource/ARTLocalDeviceStorage.mSource/ARTPushActivationEvent.mSource/ARTPushActivationState.mSource/ARTPushActivationStateMachine.mSource/ARTRest.mSource/ARTTestClientOptions.mSource/ARTThrowingLocalDeviceStorage.mSource/ARTTypes.mSource/Ably.modulemapSource/PrivateHeaders/Ably/ARTDeviceIdentityTokenDetails+Private.hSource/PrivateHeaders/Ably/ARTLocalDeviceStorage.hSource/PrivateHeaders/Ably/ARTPushActivationEvent.hSource/PrivateHeaders/Ably/ARTPushActivationState.hSource/PrivateHeaders/Ably/ARTTestClientOptions.hSource/PrivateHeaders/Ably/ARTThrowingLocalDeviceStorage.hSource/PrivateHeaders/Ably/ARTTypes+Private.hSource/include/module.modulemapTest/AblyTests/Test Utilities/MockDeviceStorage.swiftTest/AblyTests/Tests/PushTests.swift
💤 Files with no reviewable changes (6)
- Source/PrivateHeaders/Ably/ARTPushActivationEvent.h
- Source/PrivateHeaders/Ably/ARTDeviceIdentityTokenDetails+Private.h
- Source/ARTPushActivationState.m
- Source/ARTDeviceIdentityTokenDetails.m
- Source/ARTPushActivationEvent.m
- Source/PrivateHeaders/Ably/ARTPushActivationState.h
A few commits that improve logging around
LocalDevicestorage, and which also provide a new private client option to tell a client not to touch theLocalDevice. See commit messages for more details.I'm using these in my investigation of push registration issues (https://ably.atlassian.net/browse/AIT-569). See #2204 for that WIP investigation.
Summary by CodeRabbit
Release Notes
New Features
Refactor