Concurrency warning when using WKWebView#evaluateJavascript and `async let`

I'm slowly working on eliminating the Swift 6 concurrency warnings in a large Swift codebase. I recently came across a concurrency error in my code that didn't make sense to me. You can see the original code in asyncLetError below. It evaluates two JavaScript expressions concurrently (my app does something more than print them, of course).

But when compiling with Swift 6 mode on and complete currency checking, each async let line generates the following error:

ViewController.swift:9:42 Non-sendable type 'Any' returned by implicitly 
asynchronous call to main actor-isolated function cannot cross actor boundary

I guess this is because evaluateJavascript isn't explicity returning a Sendable object. I wasn't sure where to go next, so I tried moving away from the async let approach (see awaitWorksFine).

That code actually compiles perfectly, but...what's the difference? Why can't I use the async let approach? I considered filing a bug, but possibly this is expected behavior.

import UIKit
import WebKit

class ViewController: UIViewController {
  let webView: WKWebView = WKWebView(frame: .zero)

  func asyncLetError() {
    Task {
      async let urlObject = self.webView.evaluateJavaScript("document.location.href")
      async let titleObject = self.webView.evaluateJavaScript("document.title")
      
      print(try await urlObject)
      print(try await titleObject)
    }
  }
  
  func awaitWorksFine() {
    Task {
      let urlObject = try await self.webView.evaluateJavaScript("document.location.href")
      let titleObject = try await self.webView.evaluateJavaScript("document.title")
      
      print(urlObject)
      print(titleObject)
    }
  }
}

What happens when you mark the Task’s closure as @MainActor

Task { @MainActor in }

I get the same errors - no change.

This is odd to me as well. I thought maybe it had to do with evaluateJavaScript being isolated to @MainActor. Makes sense because results of evaluateJavaScript() are not guaranteed to be Sendable.

But even when we isolated the Task closure to @MainActor it also does not work.

So then I thought maybe we need to isolate the whole function (see below)

@MainActor func asyncLetErrorOnMainActor() async throws {
	async let urlObject = self.webView.evaluateJavaScript("document.location.href")
	async let titleObject = self.webView.evaluateJavaScript("document.title")

	print(try await urlObject)
	print(try await titleObject)
}

I suppose it has to do with how async let is implemented that I just don't understand where it is crossing the boundaries.

I looked at the following posts
(1) Returning non-Sendable object across actor boundaries via sending keyword

(2) https://www.hackingwithswift.com/quick-start/concurrency/how-to-call-an-async-function-using-async-let

(3) Why does `async let` discern "the origin" of its right expression?

To better understand why a value that comes from an @MainActor isolated function within an @MainActor isolated function/closure throws an error. And I couldn't figure it out.

Is this just an bug?

1 Like

This is a super interesting problem. I cannot explain why this is happening, but something about the first structure is seemly-incorrectly determining that this function is non-isolated. This would make sense if there was a synchronous evaluateJavaScript that was non-isolated, but there doesn't seem to be one.

I think it is possible you have stumbled onto a bug related to async-let and ObjC async bridging. I'd file a GitHub issue about this.

1 Like

Isn't this expected due to the nature of async let? By definition, async let creates a new child task that is being executed concurrently with the parent task. I would expect that the async let child task runs on the global executor and therefore the return value from the MainActor-isolated method has to cross isolation domains.

SE-417: Task Executor Preference says:

An async let currently [i.e. before SE-0417] always executes on the global concurrent executor, and with the inclusion of this proposal, it does take into account task executor preference. In other words, if an executor preference is set, it will be used by async let to enqueue its underlying task

I tried replacing the Task initializer with Task(executorPreference: MainActor.shared) { … }, but that doesn't compile (even though it should according to SE-0417). Error: Argument type 'MainActor' does not conform to expected type 'TaskExecutor' (Edit: this was a wrong assumption. See @jamieQ's correction below.)

1 Like

I think the real issue here is the use of Any as a return type. We can't send a plain Any across actor boundaries, but if we cast it to a Sendable type before the async let declaration, the compiler seems to be happy.

I was able to get this to compile by wrapping the evaluateJavaScript call in a function that handles casting from Any to some Sendable type T:

extension WKWebView {
  func wrappedEvaluateJavascript<T: Sendable>(_ code: String) async throws -> T? {
    let result = try await self.evaluateJavaScript(code)
    
    return result as? T
  }
}

which you call like this:

  func asyncLetFixed() {
    Task {
      async let urlObject: String? = self.webView.wrappedEvaluateJavascript("document.location.href")
      async let titleObject: String? = self.webView.wrappedEvaluateJavascript("document.title")
      
      print(try await urlObject!)
      print(try await titleObject!)
    }
  }

Not sure if this should still be considered a bug, maybe in the WKWebView interface or in the documentation.

1 Like

Wouldn’t that make async let only ever useful for capturing values that are sendable? Otherwise, it would always throw that error.

Either Sendable or sending, yes. But that's the nature of async let (and also task groups): if you want parallel execution, any values you capture or send back to the parent task must by definition cross an isolation domain.

1 Like

If the function in which the async let is called is marked as @MainActor and the function being called within the async let is also @MainActor is it possible for the value to be mutated elsewhere?

I’m trying to come up with a mental map of this happening.

It probably is possible, I just can’t currently imagine it.

#ConcurrencyIsHard :weary:

Hmm, I'm not sure. If async let unconditionally ran code on the global executor, wouldn't it allow you to remove isolation from functions?

Agreed here. It’s also marked @preconcurrency so you might need a work around for now. Another api is the callAsyncJavaScript() functions on webview but you’ll experience the same issue unless you provide a callback. You might be able to use a continuation to work around the problem

just for clarity's sake, i think this may be expected. the section of the proposal that uses MainActor.shared as a TaskExecutor is under the 'Future directions' heading. if you implement a type that conforms to TaskExecutor and enqueues its jobs on the main dispatch queue, you do get the described behavior where the async let binding will then be run on the main actor. i don't think it really helps with the issue here though as it doesn't seem like the isolation checking incorporates this information (and perhaps it cannot in general do so).

yes, and specifically, i think the issue is two-fold: the combination of the Any return type with actor-isolated code. if both the caller & callee were non-isolated here then there would be no error despite the non-Sendable return value, since then the async let binding would not 'cross' an isolation boundary.

for the straightforward cases i think you're imagining, i don't think so, but the isolation checking logic is pretty conservative so i think cannot currently be convinced such a pattern is safe without some additional overhead. it's conceivable this could be supported, but i wonder if the underlying pattern here where async let is used in a context with the same actor isolation as the initializer expression should typically be avoided.

to elaborate, the async let declaration essentially wraps the right hand side in a non-isolated closure (as Ole alluded to earlier). if both sides of the declaration are isolated to the same actor, then in some sense the primary function of the async let – to spin off a structured child task to execute in parallel with the parent – is undermined, since the effective body of the closure in such cases cannot actually start running until execution in the declaration context has suspended. additionally, since the implicit closure wrapping the init expression is non-isolated, it will 'hop' to the concurrent executor (by default) to begin, which means the ordering between two such bindings effectively starting their work will be arbitrary.

so this approach seems like it just defers execution in some straight-line code and runs it in a non-deterministic order relative to 'sibling' declarations. i struggle to think of a compelling rationale for using such a technique... but perhaps this is a lack of imagination on my part[1].

@moreindirection is there a particular reason in your motivating use-case that the async let approach is preferable to awaiting the functions directly?


  1. maybe if the functions called in the binding themselves suspend internally so that progress between more than one could be made 'in tandem' rather than having them run to completion serially? or just because it's syntactically 'lighter' than the TaskGroup API? ↩︎

3 Likes

My mistake. Thanks for correcting me.

1 Like

I decided to give SE-0317 a read, which I have never done before, to get a better handle on how async let interacts with isolation. The proposal is surprisingly unclear! However, it does have this specific call out that is relevant.

actor Worker { func work() {} }
let worker: Worker = ...

async let x = worker.work() // implicitly hops to the worker to perform the work

And this agrees with testing I've done. An async let does not allow you to escape actor isolation. However, it does introduce an additional boundary for the return values. This wasn't intuitive to me at first, but I think it makes sense now.

I cannot immediately prove that it does the same for the receiver, but it feels like that must be the case? I'm not 100% sure why I cannot come up with an example that demonstrates this, but that isn't super relevant for this discussion.

Long story short, no, async let does not allow you to get around static isolation, but does introduce additional isolation boundaries that may not be encountered with plain awaits.

1 Like

Yes, that is my understanding as well. And I think it (largely) makes sense, given that async let is specifically designed to introduce parallelism.

As @jamieQ said above, maybe the isolation checking logic is overly conservative when the expression on the right-hand side of an async let has the same static isolation as the caller.

2 Likes

apologies to the OP to further digress here, but i think this discussion is useful and illuminating.

so my mental model based on this thread, SE-317, and the 'async let' section of the region-based isolation doc is that the 'right hand side' of the async let declaration is something like a non-isolated async autoclosure with slightly special region isolation analysis rules. this means it's not just return values that cross the 'boundary' – any values going in (captures) or out (return values) of it will by default pass through a non-isolated context.

in my imagined pseudo-syntax, i think of this as something like:

// code like this:
async let value = someActor.doSomething(with: input)

// is turned into something like this:
let _asyncLet_value = { nonisolated () async -> Void [input] in
  // the implicit closure body is non-isolated, but actor-isolated
  // methods always 'hop' to where they must be
  return someActor.doSomething(with: input)
}
// implicit closure starts executing immediately...

// getting a value back out also requires moving across the
// non-isolated closure boundary
_ = await value // is like `await _asyncLet_value()`

yes, any values captured by the async let initializer expression will at least 'pass through' a non-isolated execution context. we can verify the execution context the initializer runs in by using a synchronous function/closure to provide a value to an async let binding, e.g.

@MainActor
func testAsyncLetIsolation() async {
  nonisolated func isOnMainActor() -> Bool {
    print("thread: \(Thread.current)")
    MainActor.assertIsolated()
    return true
  }

  isOnMainActor() // works

  async let _ = isOnMainActor() // assertion fails
}

as covered in SE-0417's section on async-let interactions with custom Task executors, this behavior can be altered by explicitly setting a custom TaskExecutor in an ancestor of the async let's Task hierarchy. e.g.

// for illustrative purposes only
final class MyMainActorTaskExecutor: TaskExecutor {
  static let shared = MyMainActorTaskExecutor()

  func enqueue(_ job: consuming ExecutorJob) {
    let unownedJob = UnownedJob(job)
    DispatchQueue.main.async {
      unownedJob.runSynchronously(
        isolatedTo: MainActor.sharedUnownedExecutor,
        taskExecutor: self.asUnownedTaskExecutor()
      )
    }
  }
}

func testAsyncLetIsolation_taskExecutor() async {
  await withTaskExecutorPreference(MyMainActorTaskExecutor.shared) {
    await testAsyncLetIsolation() // no longer asserts
  }
}

with a setup like this, there is no longer a 'boundary' crossing from isolated to non-isolated code, but since this is dynamic behavior, the static isolation checking can't really know that (i think).

2 Likes

This is old code, but when I wrote it, I was considering that we don't really know how WKWebView works internally, and it might be better to fire off two requests simultaneously instead of waiting for each.

That was probably premature optimization, and the JavaScript engine is single-threaded anyway, so I've addressed this problem for now by moving on to the sequential await code I posted above. The errors just didn't make sense to me.

2 Likes