Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix some bugs with async open task cancellation #8178

Merged
merged 1 commit into from
Mar 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ x.y.z Release notes (yyyy-MM-dd)
* <How to hit and notice issue? what was the impact?> ([#????](https://github.com/realm/realm-swift/issues/????), since v?.?.?)
* Add missing `@Sendable` annotations to several sync and app services related
callbacks ([PR #8169](https://github.com/realm/realm-swift/pull/8169), since v10.34.0).
* Fix some bugs in handling task cancellation for async Realm init. Some very
specific timing windows could cause crashes, and the download would not be
cancelled if the Realm was already open ([PR #8178](https://github.com/realm/realm-swift/pull/8178), since v10.37.0).

<!-- ### Breaking Changes - ONLY INCLUDE FOR NEW MAJOR version -->

Expand Down
176 changes: 134 additions & 42 deletions Realm/RLMAsyncTask.mm
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
#import "RLMAsyncTask_Private.h"

#import "RLMError_Private.hpp"
#import "RLMRealm_Private.hpp"
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like opening asynchronously a realm is getting more complex, which is needed to avoid issues. I wish at least some small comments on each of the step for opening a realm for the first time asynchronously to understand why this step by step approach is needed.

#import "RLMRealmConfiguration_Private.hpp"
#import "RLMScheduler.h"
#import "RLMSyncSubscription_Private.h"
#import "RLMUtil.hpp"

#import <realm/exceptions.hpp>
#import <realm/object-store/sync/async_open_task.hpp>
#import <realm/object-store/sync/sync_session.hpp>
#import <realm/object-store/thread_safe_reference.hpp>

static dispatch_queue_t s_async_open_queue = dispatch_queue_create("io.realm.asyncOpenDispatchQueue",
Expand All @@ -49,7 +51,7 @@ @implementation RLMAsyncOpenTask {
RLMRealmConfiguration *_configuration;
RLMScheduler *_scheduler;
bool _waitForDownloadCompletion;
RLMAsyncOpenRealmCallback _completion;
void (^_completion)(NSError *);

RLMRealm *_backgroundRealm;
}
Expand Down Expand Up @@ -82,22 +84,13 @@ - (void)cancel {
_progressBlocks.clear();
if (_task) {
_task->cancel();
// Cancelling realm::AsyncOpenTask results in it never calling our callback,
// so if we're currently in that we have to just send the cancellation
// error immediately. In all other cases, though, we want to wait until
// we've actually cancelled and will send the error the next time we
// check for cancellation
[self reportError:s_canceledError];
}
[self reportError:s_canceledError];
}

- (void)setTask:(std::shared_ptr<realm::AsyncOpenTask>)task __attribute__((objc_direct)) {
std::lock_guard lock(_mutex);
if (_cancel) {
task->cancel();
return;
}

_task = task;
for (auto& block : _progressBlocks) {
_task->register_download_progress_notifier(block);
}
_progressBlocks.clear();
}

- (instancetype)initWithConfiguration:(RLMRealmConfiguration *)configuration
Expand Down Expand Up @@ -127,12 +120,22 @@ - (instancetype)initWithConfiguration:(RLMRealmConfiguration *)configuration
}

- (void)waitForOpen:(RLMAsyncOpenRealmCallback)completion {
{
std::lock_guard lock(_mutex);
_completion = completion;
if (_cancel) {
return [self reportError:s_canceledError];
__weak auto weakSelf = self;
[self waitWithCompletion:^(NSError *error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Wait is the purpose of separating this into two steps?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is both to sidestep the sendable checking issues with cancellation handlers and to align the APIs for the two tasks so that the Swift code doesn't need to be duplicated. Passing a RLMRealm to the completion handler gives a spurious warning because when it's wrapped in a completion handler there's potentially a hop away from the correct executor and then back, and reading it from the token instead avoids that. These means that Swift wants a completion handler which just returns an error for both task types, but the obj-c (and Swift Realm.asyncOpen()) API needs a completion handler with a RLMRealm and error, so this wraps the applicable one to expose that.

RLMRealm *realm;
if (auto self = weakSelf) {
realm = self->_localRealm;
self->_localRealm = nil;
}
completion(realm, error);
}];
}

- (void)waitWithCompletion:(void (^)(NSError *))completion {
std::lock_guard lock(_mutex);
_completion = completion;
if (_cancel) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we do [self checkCancellation] here?

return [self reportError:s_canceledError];
}

// get_synchronized_realm() synchronously opens the DB and performs file-format
Expand All @@ -144,23 +147,52 @@ - (void)waitForOpen:(RLMAsyncOpenRealmCallback)completion {
});
}

// The full async open flow is:
// 1. Dispatch to a background queue
// 2. Use Realm::get_synchronized_realm() to create the Realm file, run
// migrations and compactions, and download the latest data from the server.
// 3. Dispatch back to queue
// 4. Initialize a RLMRealm in the background queue to perform the SDK
// initialization (e.g. creating managed accessor classes).
// 5. Wait for initial flexible sync subscriptions to complete
// 6. Dispatch to the final scheduler
// 7. Open the final RLMRealm, release the previously opened background one,
// and then invoke the completion callback.
//
// Steps 2 and 5 are skipped for non-sync or non-flexible sync Realms, in which
// case step 4 will handle doing migrations and compactions etc. in the background.
//
// At any point `cancel` can be called from another thread. Cancellation is mostly
// cooperative rather than preemptive: we check at each step if we've been cancelled,
// and if so call the completion with the cancellation error rather than
// proceeding. Downloading the data from the server is the one exception to this.
// Ideally waiting for flexible sync subscriptions would also be preempted.
- (void)startAsyncOpen {
std::unique_lock lock(_mutex);
if ([self checkCancellation]) {
return;
}

if (_waitForDownloadCompletion && _configuration.configRef.sync_config) {
#if REALM_ENABLE_SYNC
auto task = realm::Realm::get_synchronized_realm(_configuration.config);
self.task = task;
task->start([=](realm::ThreadSafeReference ref, std::exception_ptr err) {
_task = realm::Realm::get_synchronized_realm(_configuration.config);
for (auto& block : _progressBlocks) {
_task->register_download_progress_notifier(block);
}
_progressBlocks.clear();
_task->start([=](realm::ThreadSafeReference ref, std::exception_ptr err) {
std::lock_guard lock(_mutex);
if ([self checkCancellation]) {
return;
}
// Note that cancellation intentionally trumps reporting other kinds
// of errors
if (err) {
return [self reportException:err];
}

// Dispatch blocks can only capture copyable types, so we need to
// resolve the TSR to a shared_ptr<Realm>
auto realm = ref.resolve<std::shared_ptr<realm::Realm>>(nullptr);
// We're now running on the sync worker thread, so hop back
// to a more appropriate queue for the next stage of init.
Expand All @@ -178,12 +210,15 @@ - (void)startAsyncOpen {
#endif
}
else {
// We're not downloading first, so just pretend it completed successfully
// We're not downloading first, so just proceed directly to the next step.
lock.unlock();
[self downloadCompleted];
}
}

- (void)downloadCompleted {
std::unique_lock lock(_mutex);
_task.reset();
if ([self checkCancellation]) {
return;
}
Expand Down Expand Up @@ -212,36 +247,53 @@ - (void)downloadCompleted {
return [subscriptions waitForSynchronizationOnQueue:nil
completionBlock:^(NSError *error) {
if (error) {
std::lock_guard lock(_mutex);
return [self reportError:error];
}
[self completeAsyncOpen];
[self asyncOpenCompleted];
}];
}
}
#endif
[self completeAsyncOpen];
lock.unlock();
[self asyncOpenCompleted];
}

- (void)completeAsyncOpen {
if ([self checkCancellation]) {
return;
- (void)asyncOpenCompleted {
std::lock_guard lock(_mutex);
if (![self checkCancellation]) {
[_scheduler invoke:^{
[self openFinalRealmAndCallCompletion];
}];
}
}

[_scheduler invoke:^{
@autoreleasepool {
NSError *error;
RLMRealm *localRealm = [RLMRealm realmWithConfiguration:_configuration
confinedTo:_scheduler
error:&error];
auto completion = _completion;
[self releaseResources];
completion(localRealm, error);
- (void)openFinalRealmAndCallCompletion {
std::unique_lock lock(_mutex);
@autoreleasepool {
if ([self checkCancellation]) {
return;
}
}];
if (!_completion) {
return;
}
NSError *error;
auto completion = _completion;
// It should not actually be possible for this to fail
_localRealm = [RLMRealm realmWithConfiguration:_configuration
confinedTo:_scheduler
error:&error];
[self releaseResources];

lock.unlock();
completion(error);
}
}

- (bool)checkCancellation {
std::lock_guard lock(_mutex);
if (_cancel && _completion) {
[self reportError:s_canceledError];
Copy link
Contributor

Choose a reason for hiding this comment

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

In case of cancel, shouldn't we unlock the lock as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

reportError: needs to be called with the lock held. The callback is invoked asynchronously on the scheduler so it's not invoked in a context where we're holding the lock.

}
return _cancel;
}

Expand All @@ -263,11 +315,15 @@ - (void)reportException:(std::exception_ptr const&)err {
}

- (void)reportError:(NSError *)error {
if (!_completion || !_scheduler) {
return;
}

auto completion = _completion;
auto scheduler = _scheduler;
[self releaseResources];
[scheduler invoke:^{
completion(nil, error);
completion(error);
}];
}

Expand All @@ -278,3 +334,39 @@ - (void)releaseResources {
_completion = nil;
}
@end

@implementation RLMAsyncDownloadTask {
RLMUnfairMutex _mutex;
std::shared_ptr<realm::SyncSession> _session;
bool _started;
}

- (instancetype)initWithRealm:(RLMRealm *)realm {
if (self = [super init]) {
_session = realm->_realm->sync_session();
}
return self;
}

- (void)waitWithCompletion:(void (^)(NSError *_Nullable))completion {
std::unique_lock lock(_mutex);
if (!_session) {
lock.unlock();
return completion(nil);
}

_started = true;
_session->revive_if_needed();
_session->wait_for_download_completion([=](realm::Status status) {
completion(makeError(status));
});
}

- (void)cancel {
std::unique_lock lock(_mutex);
if (_started) {
_session->force_close();
}
_session = nullptr;
}
@end
12 changes: 10 additions & 2 deletions Realm/RLMAsyncTask_Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ __attribute__((objc_direct));
- (instancetype)initWithConfiguration:(RLMRealmConfiguration *)configuration
confinedTo:(RLMScheduler *)confinement
download:(bool)waitForDownloadCompletion;
- (void)waitForOpen:(RLMAsyncOpenRealmCallback)completion
__attribute__((swift_attr("@_unsafeInheritExecutor")));

- (void)waitWithCompletion:(void (^)(NSError *_Nullable))completion;
- (void)waitForOpen:(RLMAsyncOpenRealmCallback)completion __attribute__((objc_direct));
@end

RLM_SWIFT_SENDABLE
@interface RLMAsyncDownloadTask : NSObject
- (instancetype)initWithRealm:(RLMRealm *)realm;
- (void)cancel;
- (void)waitWithCompletion:(void (^)(NSError *_Nullable))completion;
@end

RLM_HEADER_AUDIT_END(nullability)
48 changes: 33 additions & 15 deletions RealmSwift/Realm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1220,9 +1220,10 @@ extension Realm {
if let realm = realm {
// This can't be hit on the first open so .once == .never
if downloadBeforeOpen == .always {
try await realm.waitForDownloadCompletion()
let task = RLMAsyncDownloadTask(realm: realm)
try await task.waitWithCancellationHandler()
}
self = Realm(realm)
rlmRealm = realm
return
}

Expand All @@ -1231,18 +1232,8 @@ extension Realm {
let task = RLMAsyncOpenTask(configuration: rlmConfiguration, confinedTo: scheduler,
download: shouldAsyncOpen(configuration, downloadBeforeOpen))
do {
// Work around https://github.com/apple/swift/issues/61119, which
// makes it impossible to use withTaskCancellationHandler() from
// within an isolated function without getting warnings
nonisolated func workaround() async throws {
try await withTaskCancellationHandler { @Sendable in
task.localRealm = try await task.waitForOpen()
} onCancel: { @Sendable in
task.cancel()
}
}
try await workaround()
self = Realm(task.localRealm!)
try await task.waitWithCancellationHandler()
rlmRealm = task.localRealm!
task.localRealm = nil
} catch {
// Check if the task was cancelled and if so replace the error
Expand All @@ -1252,7 +1243,34 @@ extension Realm {
}
}
}
#endif // swift(>=5.5)

@available(macOS 10.15, tvOS 13.0, iOS 13.0, watchOS 6.0, *)
private protocol TaskWithCancellation: Sendable {
func waitWithCancellationHandler() async throws
func wait() async throws
func cancel()
}

@available(macOS 10.15, tvOS 13.0, iOS 13.0, watchOS 6.0, *)
extension TaskWithCancellation {
func waitWithCancellationHandler() async throws {
Copy link
Contributor

Choose a reason for hiding this comment

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

Cool way to do the implementation for both RLMAsyncOpenTask and RLMAsyncDownloadTask

do {
try await withTaskCancellationHandler {
try await wait()
} onCancel: {
cancel()
}
} catch {
// Check if the task was cancelled and if so replace the error
// with reporting cancellation
try Task.checkCancellation()
throw error
}
}
}
extension RLMAsyncOpenTask: TaskWithCancellation {}
Copy link
Contributor

Choose a reason for hiding this comment

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

From the code, I understand that in a async await context we now use RLMAsyncDownloadTask if the realm is already open and RLMAsyncOpenTask if is the first time. Is that correct?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes. The design of task cancellation in Swift means that any cancellable operation needs an object which represents that operation. RLMAsyncDownloadTask is mostly just an optimization - we could just go through the normal async open flow even if the Realm is already open - but I think the design of actor-confined Realms will lead to people hitting that code path fairly often.

Copy link
Contributor

Choose a reason for hiding this comment

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

Very cool 🤗

extension RLMAsyncDownloadTask: TaskWithCancellation {}
#endif // canImport(_Concurrency)

/**
Objects which can be fetched from the Realm - Object or Projection
Expand Down