Skip to content

Support cancellation of tasks with FetchHTTPClient#157

Open
xbhatnag wants to merge 1 commit into
mainfrom
fetch-client
Open

Support cancellation of tasks with FetchHTTPClient#157
xbhatnag wants to merge 1 commit into
mainfrom
fetch-client

Conversation

@xbhatnag
Copy link
Copy Markdown
Collaborator

@xbhatnag xbhatnag commented May 8, 2026

The client now supports cancellation during fetch() API call or during reading of the response body!

The example app is now configured to cancel a request after 10 seconds to demonstrate this functionality.

Screenshot 2026-05-08 at 12 16 18 PM

The client now supports cancellation during `fetch()` API call
and during reading of response body.
@xbhatnag xbhatnag requested review from FranzBusch and guoye-zhang May 8, 2026 19:17
@xbhatnag xbhatnag self-assigned this May 8, 2026
@xbhatnag xbhatnag added the 🔨 semver/patch No public API change. label May 8, 2026
@JSClass(from: .global) struct AbortController: @unchecked Sendable {
@JSGetter var signal: JSObject?
@JSFunction init() throws(JSException)
@JSFunction func abort() throws(JSException)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are these functions safe to call from any threads?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm no expert on how this translates to JS, but isn't JS single-threaded, making WASM single-threaded, making all the asynchronous contexts in Swift also single-threaded?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I believe that multithreading is supported in WASM: https://webassembly.org/features/

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Today I learned :) @MaxDesiatov might know more about this specific question...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Our Swift SDK for Wasm is single-threaded by default. Multi-threading is available as an option, so I don't think @unchecked Sendable is justifiable here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any JS methods against a JSObject should be called on the thread on which the JSObject is created. So as long as those methods are called on the thread the AbortController instance is created on, it's safe. However if we cannot guarantee that, we need to use JSRemote<T> to call them safely: swiftwasm/JavaScriptKit#711

Copy link
Copy Markdown
Collaborator

@guoye-zhang guoye-zhang May 15, 2026

Choose a reason for hiding this comment

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

How does JS thread map to Swift concurrency? There is no guarantee of the thread that an async method runs on since threads can switch arbitrarily after an await right? Should we wrap everything in JSRemote or just use @MainActor? (I assume that MainActor maps to the main thread in JS as well)

Copy link
Copy Markdown

@kateinoigakukun kateinoigakukun May 16, 2026

Choose a reason for hiding this comment

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

MainActor-isolated code is consistently mapped to the browser main thread in any case, and there is no guarantee that any other async code runs on. However, practically speaking, JavaScriptKit does not provide a global concurrent executor even under a multithreading environment, and it just provides TaskExecutor implementations, and those implementations guarantee no thread hop in a structured async code so that client code can control which thread a specific code can run on in an opt-in way.

I took a closer look on the code, and I can say FetchHTTPClient.perform itself is safe because all JS object access happens within a structured code, but ResponseBodyReader.read has to use JSRemote because the caller might be running on a different thread than the thread FetchHTTPClient.perform was called.

After swiftwasm/JavaScriptKit#747 lands, we can fix the potential thread issue by the following patch.

Patch
diff --git a/Sources/FetchHTTPClient/FetchHTTPClient.swift b/Sources/FetchHTTPClient/FetchHTTPClient.swift
index cb6825e..d5c4bc4 100644
--- a/Sources/FetchHTTPClient/FetchHTTPClient.swift
+++ b/Sources/FetchHTTPClient/FetchHTTPClient.swift
@@ -120,7 +120,7 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient {

         return try await responseHandler(
             HTTPResponse(status: .init(code: responseStatus, reasonPhrase: responseStatusText), headerFields: responseHeaders),
-            ResponseReader(reader: reader)
+            ResponseReader(reader: JSRemote(reader))
         )
     }

@@ -145,7 +145,7 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient {
     }

     public struct ResponseReader: ConcludingAsyncReader, ~Copyable {
-        let reader: ReadableStreamDefaultReader
+        let reader: JSRemote<ReadableStreamDefaultReader>

         public consuming func consumeAndConclude<Return, Failure>(
             body: nonisolated(nonsending) (consuming sending FetchHTTPClient.ResponseBodyReader) async throws(Failure) -> Return
@@ -159,7 +159,7 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient {
         public typealias ReadFailure = any Error
         public typealias Buffer = UniqueArray<UInt8>

-        let reader: ReadableStreamDefaultReader
+        let reader: JSRemote<ReadableStreamDefaultReader>
         var buffer = UniqueArray<UInt8>()

         public mutating func read<Return: ~Copyable, Failure>(
@@ -167,26 +167,35 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient {
+        let reader: JSRemote<ReadableStreamDefaultReader>
         var buffer = UniqueArray<UInt8>()

         public mutating func read<Return: ~Copyable, Failure>(
@@ -167,26 +167,35 @@ public final class FetchHTTPClient: HTTPAPIs.HTTPClient {
         ) async throws(AsyncStreaming.EitherError<any Error, Failure>) -> Return where Failure: Error {
             let chunk: Chunk
             do {
-                chunk = try await self.reader.read()
+                chunk = try await reader.withJSObject { reader in
+                    try await reader.read()
+                }
+            } catch {
+                throw .first(error)
+            }
+            do {
+                try self.append(chunk: chunk)
             } catch {
                 throw .first(error)
             }
+            do {
+                return try await body(&self.buffer)
+            } catch {
+                throw .second(error)
+            }
+        }
+
+        private mutating func append(chunk: Chunk) throws {
             if !chunk.done {
                 guard let bytes = chunk.value, !bytes.isEmpty else {
                     // If not done, there must be bytes that can be read
-                    throw .first(FetchError.BadAssumptionJS)
+                    throw FetchError.BadAssumptionJS
                 }
                 buffer.reserveCapacity(bytes.count)
                 for b in bytes {
                     self.buffer.append(b)
                 }
             }
-
-            do {
-                return try await body(&self.buffer)
-            } catch {
-                throw .second(error)
-            }
         }
     }
 }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hope to understand a bit more how this works. Is fetch allowed to be called from any thread? Does JSRemote internally switch back to the creating thread to call methods?

Wondering if there is an efficiency difference between wrapping the reader in JSRemote or just slapping MainActor on everything to let Swift handle thread hopping.

Copy link
Copy Markdown
Member

@MaxDesiatov MaxDesiatov May 18, 2026

Choose a reason for hiding this comment

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

Can we get around by making it non-Sendable in this PR (@MainActor could even maybe be acceptable?), and then following up with JSRemote approach in a future PR when upstream JavaScriptKit has everything ready for that?

@@ -76,6 +85,7 @@ import JavaScriptKit
// TODO: Find a way to remove the @unchecked. This object has to be moved through the different Swift reader types.
@JSClass(from: .global) struct ReadableStreamDefaultReader: @unchecked Sendable {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ditto, @unchecked Sendable is most probably not correct here.

cc @kateinoigakukun

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

Labels

🔨 semver/patch No public API change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants