facebook / react-native

A framework for building native applications using React
https://reactnative.dev
MIT License
119.52k stars 24.37k forks source link

Android `fetch` hangs indefinitely with IPv6 hosts on some devices (Happy Eyeballs) #32730

Open andreialecu opened 2 years ago

andreialecu commented 2 years ago

Description

This is a very bizarre issue that has been previously reported a bunch of times, and this is basically a continuation of:

https://github.com/facebook/react-native/issues/29608

I initially started running into this on RN 0.66 with AWS Cognito. Bumping to 0.66.3 didn't help.

I'm also pretty sure this used to work before and I'm not sure when it broke. It's on an app that has been shelved for a while.

The problem is very strange because the network request does not seem to be issued, but simply hitting CMD+S to save any file so that a hot-reload is issued will immediately dispatch the network request.

I discovered the promise hanging issue by adding some logs to the fetch calls the cognito library was doing: image Notice how the .then is not executed.

While troubleshooting I came across a mention here of a workaround: https://github.com/facebook/react-native/issues/29608#issuecomment-884521699 (courtesy of @danmaas) which seems to completely resolve the issue.

Here's the same .then correctly being executed after applying that patch: image

Version

0.66.3

Output of react-native info

System:
    OS: macOS 12.0.1
    CPU: (8) arm64 Apple M1
    Memory: 142.27 MB / 16.00 GB
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 16.13.0 - /private/var/folders/9p/k1yqxx0d7rn1nlztg_wm7sbw0000gn/T/xfs-2dcae145/node
    Yarn: 2.4.0-git.20210330.hash-ebcd71d5 - /private/var/folders/9p/k1yqxx0d7rn1nlztg_wm7sbw0000gn/T/xfs-2dcae145/yarn
    npm: 7.20.1 - ~/.nvm/versions/node/v16.13.0/bin/npm
    Watchman: 2021.11.01.00 - /opt/homebrew/bin/watchman
  Managers:
    CocoaPods: 1.11.0 - /Users/andreialecu/.rbenv/shims/pod
  SDKs:
    iOS SDK:
      Platforms: DriverKit 21.0.1, iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0
    Android SDK: Not Found
  IDEs:
    Android Studio: 4.2 AI-202.7660.26.42.7351085
    Xcode: 13.1/13A1030d - /usr/bin/xcodebuild
  Languages:
    Java: 11.0.8 - /Applications/Android Studio.app/Contents/jre/jdk/Contents/Home/bin/javac
  npmPackages:
    @react-native-community/cli: Not Found
    react: 17.0.2 => 17.0.2 
    react-native: 0.66.3 => 0.66.3 
    react-native-macos: Not Found
  npmGlobalPackages:
    *react-native*: Not Found

Steps to reproduce

I'm able to reproduce it with this:

  React.useEffect(() => {
    console.log('confirm start', new Date());
    fetch('https://cognito-idp.eu-west-1.amazonaws.com/', {
      method: 'POST',
      mode: 'cors',
    })
      .then(() => console.log('then', new Date()))
      .catch(() => console.log('catch', new Date()));
    setTimeout(() => {
      console.log('5 seconds passed');
    }, 5000);
  }, []);

Output:

Screenshot 2021-12-09 at 19 29 06

After applying https://github.com/facebook/react-native/issues/29608#issuecomment-884521699:

Screenshot 2021-12-09 at 19 30 32

Snack, code example, screenshot, or link to a repository

No response

Skip to this comment for the actual cause: https://github.com/facebook/react-native/issues/32730#issuecomment-990764376

shubhkhanna commented 1 month ago

@shubhkhanna

For me it solved the problem for IPv6 support on android.

How to implement this in Expo project?

@JKKholmatov never tried in the expo project. These changes are for bare react-native project.

whybtech commented 1 month ago

@JKKholmatov I used a custom plugin with expo/config-plugins and withAppBuildGradle, here is an example:

module.exports = withAppBuildGradleDependencies = (config, customName) => { return withAppBuildGradle(config, (config) => { const initialIndex = config.modResults.contents.indexOf("dependencies {");

config.modResults.contents =
  config.modResults.contents.slice(0, initialIndex) +
  `dependencies {
implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
implementation 'com.squareup.okhttp3:okhttp-urlconnection:5.0.0-alpha.11'` +
  config.modResults.contents.slice(initialIndex + "dependencies {".length);

return config;

}); };

* Declare the plugin in `app.json`:
```json
"plugins": [
      "./internal/expo-config-plugins/appBuildGradleDependencies.js",

It resolved my issue with ipv6 latency on Android

khakobyan commented 1 month ago

Switching to OkHttp version 5 did not resolve the issue. However, I found a solution by creating a custom module for API calls that only functions when running on Android and the required VPN is active. Below is the implementation:

package <com.yourpackage.test>

import android.net.TrafficStats
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Headers.Companion.toHeaders
import okhttp3.Dns
import okhttp3.Response
import java.io.IOException
import java.net.Inet4Address
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.bridge.Arguments

class OkHttpModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {

    override fun getName(): String {
        return "OkHttpModule"
    }

    @ReactMethod
    fun fetchFromApi(url: String, headersString: String, body: String, promise: Promise) {
        // Create OkHttpClient with IPv4-only DNS resolution
        val client = OkHttpClient.Builder()
            .dns { hostname ->
                // Resolve only IPv4 addresses (filter out IPv6 addresses)
                Dns.SYSTEM.lookup(hostname).filter { it is Inet4Address }
            }
            .build()

        // Tag the thread for traffic stats tracking
        TrafficStats.setThreadStatsTag(10042)

        try {
            // Convert headers from a string to OkHttp Headers
            val headers = parseHeaders(headersString).toHeaders()

            // Create the request body
            val requestBody = RequestBody.create("application/x-www-form-urlencoded".toMediaTypeOrNull(), body)

            // Build the request
            val request = Request.Builder()
                .url(url)
                .headers(headers)
                .post(requestBody)
                .build()

            // Execute the request
            val response: Response = client.newCall(request).execute()
            val responseData = response.body?.string() ?: ""

            // Send result back to JS for logging
            sendEventToJS(reactApplicationContext, url, responseData, headers.toMap())

            if (!response.isSuccessful) {
                promise.reject("Error", "Request failed with code: ${response.code}")
            } else {
                promise.resolve(responseData)
            }

        } catch (e: IOException) {
            promise.reject("Error", "Request failed: ${e.message}")
        } finally {
            // Clear the traffic stats tag after the network operation is complete
            TrafficStats.clearThreadStatsTag()
        }
    }

    private fun sendEventToJS(reactContext: ReactApplicationContext, url: String, response: String, headers: Map<String, String>) {
        val params = Arguments.createMap()
        params.putString("url", url)
        params.putString("response", response)
        params.putString("headers", headers.toString())

        reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
            .emit("networkResponse", params)
    }

    // Helper method to parse headers from "Key: Value\nKey: Value" format into a map
    private fun parseHeaders(headersString: String): Map<String, String> {
        val headersMap = mutableMapOf<String, String>()
        val headersArray = headersString.split("\n")
        for (header in headersArray) {
            val keyValue = header.split(": ")
            if (keyValue.size == 2) {
                headersMap[keyValue[0]] = keyValue[1]
            }
        }
        return headersMap
    }}

This module ensures the API calls work specifically with IPv4, mitigating connectivity issues observed when using the VPN.

yschimke commented 1 month ago

I'd be interested to understand why it breaks on VPN.

If you configure an event listener in the client, you should see what it's doing with DNS and connection attempts.

This should work without this workaround, so it would be good to find the root cause.

https://square.github.io/okhttp/features/events/

khakobyan commented 1 month ago

@yschimke

The issue is actually rooted in how Android handles connections with the Happy Eyeballs algorithm, which tries both IPv4 and IPv6 connections to improve speed. On certain devices, though, this approach can cause requests to hang indefinitely when there’s an IPv6 host involved—especially problematic when using a VPN that primarily supports IPv4.

To work around this, I created a custom module that forces OkHttp to resolve only IPv4 addresses, which has been effective in preventing the hang. Ideally, we’d look deeper into how Android handles the Happy Eyeballs fallback in VPN contexts and see if we can prevent this hang without restricting to IPv4.

yschimke commented 1 month ago

OkHttp tries to

Which section is hanging with a VPN?

khakobyan commented 1 month ago

@yschimke The hang isn’t actually with OkHttp itself but with fetch in JavaScript, which stalls on IPv6 with some VPN setups on Android. We’re using OkHttp in a custom module to replace fetch on Android, forcing IPv4 resolution to bypass this issue. This could technically be done with other libraries, but OkHttp makes managing DNS settings straightforward and effective here.