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

Realm does not work with Async Specs #1218

Open
skywalkerdude opened this issue May 23, 2023 · 9 comments
Open

Realm does not work with Async Specs #1218

skywalkerdude opened this issue May 23, 2023 · 9 comments

Comments

@skywalkerdude
Copy link

skywalkerdude commented May 23, 2023

  • [ x] I have read CONTRIBUTING and have done my best to follow them.

What did you do?

Upgraded from Quick 6.1.0 to Quick 7.0.0.

Trying to listen on a Combine Publisher from Realm, and getting the error Thread 5: "Can only add notification blocks from within runloops."

What did you expect to happen?

Runs normally as with Quick 6.0

What actually happened instead?

Thread 5: "Can only add notification blocks from within runloops."

Environment

List the software versions you're using:

  • Quick: 7.0.0
  • Nimble: 12.0.0
  • Xcode Version: 14.3 (Open Xcode; In menubar: Xcode > About Xcode)
  • Swift Version: swift-driver version: 1.75.2 Apple Swift version 5.8 (swiftlang-5.8.0.124.2 clang-1403.0.22.11.100)
    Target: x86_64-apple-macosx13.0
    (Open Xcode Preferences; Components > Toolchains. If none, use Xcode Default.)

Please also mention which package manager you used and its version. Delete the
other package managers in this list:

  • CocoaPods: 1.21.1 (Use pod --version in Terminal)

Project that demonstrates the issue

class TestRealmImplSpec: AsyncSpec {
    var failure: XCTestExpectation!
    var finished: XCTestExpectation!
    var value: XCTestExpectation!
    var cancellable: AnyCancellable?

    override class func spec() {
        beforeEach {
            failure = current.expectation(description: "Invalid.failure")
            finished = current.expectation(description: "Invalid.failure")
            value = current.expectation(description: "Invalid.failure")
        }
        it("should work)" {
            cancellable = realmObject.getPublisher()
                .sink(receiveCompletion: { (completion: Subscribers.Completion<ErrorType>) -> Void in
                    switch completion {
                        case .failure:
                            failure.fulfill()
                        case .finished:
                            finished.fulfill()
                        }
                        return
                }, receiveValue: { isFavorite in
                    value.fulfill()
            })
        }
    }
}

This will error on the .sink(receiveCompletion: { (completion: Subscribers.Completion<ErrorType>) -> Void in line saying that Realm Can only add notification blocks from within runloops..

I can give a more complete example if you need, but my guess is, the move of spec() from an instance method to a class method meant that the thread running the test no longer has a run loop?

I tried debugging a bit on my own, and I found some resources that may or may not be related:
https://academy.realm.io/posts/realm-notifications-on-background-threads-with-swift/
https://github.com/Quick/Nimble/pull/549/files

@younata
Copy link
Member

younata commented May 23, 2023

Fascinating.

the move of spec() from an instance method to a class method meant that the thread running the test no longer has a run loop?

That shouldn't be the case here. This move only affects how tests get created & turned into instance methods that XCTest then calls. Unless you're creating realmObject outside of a beforeEach/afterEach/it block, then that change shouldn't be the cause.

I'd love to see additional context here.

I spent some time trying to recreate this. Admittedly, I haven't used realm before, so this is based entirely on their Quickstart guide, but I'm not running into any errors, and this test passes.

import RealmSwift
import Quick
import Nimble
import XCTest
import Combine

final class SampleSpec: AsyncSpec {
    override class func spec() {
        var realm: Realm!

        var thing: Obj!

        var failure: XCTestExpectation!
        var finished: XCTestExpectation!
        var value: XCTestExpectation!
        var cancellable: AnyCancellable?

        beforeEach {
            Realm.Configuration.defaultConfiguration.inMemoryIdentifier = current.name
            realm = try! Realm()

            thing = Obj()

            try! realm.write {
                realm.add(thing)
            }

            failure = current.expectation(description: "Invalid.failure")
            failure.isInverted = true
            finished = current.expectation(description: "Invalid.failure")
            value = current.expectation(description: "Invalid.failure")
        }

        afterEach {
            await MainActor.run {
                current.waitForExpectations(timeout: 10)
            }
        }

        it("works?") {
            cancellable = thing.publisher
                .sink(receiveCompletion: { (completion: Subscribers.Completion<Never>) -> Void in
                    switch completion {
                    case .failure:
                        failure.fulfill()
                    case .finished:
                        finished.fulfill()
                    }
                    return
                }, receiveValue: { isFavorite in
                    value.fulfill()
                })


        }
    }
}

class Obj: Object {
    @Persisted var name: String = ""
}

@younata
Copy link
Member

younata commented Jun 30, 2023

I haven't heard back in over a month on this. I'm going to close this as unable to reproduce.

@younata younata closed this as not planned Won't fix, can't repro, duplicate, stale Jun 30, 2023
@skywalkerdude
Copy link
Author

skywalkerdude commented Aug 7, 2023

@younata Apologies for the delay. I was out for a while and unable to work on this.

But thank you for your code sample! I edited here so to reproduce the error:

import RealmSwift
import Quick
import Nimble
import XCTest
import Combine

// swiftlint:disable all
final class SampleSpec: AsyncSpec {
    override class func spec() {
        var realm: Realm!

        var thing: Obj!

        var failure: XCTestExpectation!
        var finished: XCTestExpectation!
        var value: XCTestExpectation!
        var cancellable: AnyCancellable?

        beforeEach {
            Realm.Configuration.defaultConfiguration.inMemoryIdentifier = current.name
            realm = try! Realm()

            thing = Obj()

            try! realm.write {
                realm.add(thing)
            }

            failure = current.expectation(description: "Invalid.failure")
            failure.isInverted = true
            finished = current.expectation(description: "Invalid.failure")
            value = current.expectation(description: "Invalid.failure")
        }

        afterEach {
            await MainActor.run {
                current.waitForExpectations(timeout: 10)
            }
            cancellable?.cancel()
        }

        it("works?") {
            cancellable = realm.objects(Obj.self)
                .collectionPublisher
                .sink(receiveCompletion: { completion -> Void in
                    switch completion {
                    case .failure:
                        failure.fulfill()
                    case .finished:
                        finished.fulfill()
                    }
                    return
                }, receiveValue: { isFavorite in
                    value.fulfill()
                })
        }
    }
}

class Obj: Object {
    @Persisted var name: String = ""
}

Long story short, if you use realm.objects... to grab objects from the realm item itself, then it will throw the error.

Let me know if that helps with reproducing it!

@skywalkerdude
Copy link
Author

I seem to be unable to reopen this issue. Should I create a new issue, or is commenting on here sufficient, @younata?

@younata younata reopened this Aug 7, 2023
@younata
Copy link
Member

younata commented Aug 7, 2023

I seem to be unable to reopen this issue. Should I create a new issue, or is commenting on here sufficient.

Odd. Well, I just re-opened this.

Thanks for the extra details, and I'll look at this soon.

@skywalkerdude
Copy link
Author

@younata circling back to this to see if you've had a chance to take a look? It's still happening with Quick 7.3.0 (although #1237 was indeed fixed, like you said!)

@skywalkerdude
Copy link
Author

friendly ping @younata :)

@younata
Copy link
Member

younata commented Jan 10, 2024

Thanks for the ping!

This is... fascinating. I still don't have a full understanding of what's going on (why is Realm getting a null RunLoopMode for the current runloop?), but this is definitely an issue with using Realm from background threads (as this exception is not thrown if you use a QuickSpec, or if you make sure to only access the Realm from the main actor).

This is also an issue in Quick 6 (but not an issue in Quick 5 - because Quick 5 tests only run on the main thread), and also in XCTest if you use async tests. Leading me to believe that this is not so much a Quick issue as a Realm issue, in particular with Realm not working well with Swift Concurrency.
This makes sense to me, because Swift Concurrency makes almost no guarantees about which thread a given closure will run on, something that Realm and most other databases really want to ensure.

For the time being, I'll say that you can work around this issue by only accessing the realm from the main thread. Either by using a QuickSpec instead of AsyncSpec, or by applying @MainActor/MainActor.run to the appropriate closures. I'll leave this open, and change the title of the issue to "Realm does not work with Async Specs".

@younata younata changed the title Quick 7.0.0 breaks Realm + Combine Realm does not work with Async Specs Jan 10, 2024
@skywalkerdude
Copy link
Author

Thanks for the workaround suggestion!

I'm not sure if I quite understand what you mean, though. If I change the above example to use QuickSpec, then I get on afterEach: Cannot pass function of type '() async -> Void' to parameter expecting synchronous function type.

I'm also not entirely sure what to apply @MainActor or MainActor.run. Would you be able to help apply that workaround to the sample code in #1218 (comment) to show me what you mean?

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

No branches or pull requests

2 participants