diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 150350090ab1..349eb8a1e9b8 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -179,7 +179,8 @@ "react-native-screens", "react-native-reanimated", "@react-native-menu/menu", - "expo-audio" + "expo-audio", + "expo-network" ] } }, diff --git a/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift new file mode 100644 index 000000000000..db279d0c62e7 --- /dev/null +++ b/apps/mobile/scripts/fixtures/NetworkModuleRegression.swift @@ -0,0 +1,80 @@ +import Foundation + +enum NWInterface { + enum InterfaceType { + case other, wifi, cellular, loopback, wiredEthernet + } +} + +struct NWPath { + enum Status { + case satisfied, unsatisfied + } + + // A reference inside the value models the native path's retained storage. + final class Storage { + let type: NWInterface.InterfaceType + init(_ type: NWInterface.InterfaceType) { self.type = type } + } + + let status: Status + let storage: Storage + + init(_ type: NWInterface.InterfaceType, status: Status = .satisfied) { + self.status = status + storage = Storage(type) + } + + func usesInterfaceType(_ type: NWInterface.InterfaceType) -> Bool { + storage.type == type + } +} + +final class NWPathMonitor { + static var scenario = "first" + static let deliveries = DispatchGroup() + var pathUpdateHandler: ((NWPath) -> Void)? + + func start(queue: DispatchQueue) { + guard let handler = pathUpdateHandler else { fatalError("Missing path handler") } + if Self.scenario == "offline" { + handler(NWPath(.wifi, status: .unsatisfied)) + } else if Self.scenario == "first" { + handler(NWPath(.wifi)) + handler(NWPath(.cellular)) + } else { + queue.async(group: Self.deliveries) { + handler(NWPath(.wifi)) + DispatchQueue.concurrentPerform(iterations: 128) { index in + handler(NWPath(index.isMultiple(of: 2) ? .cellular : .wiredEthernet)) + } + } + } + } + + // Already queued callbacks can run after cancellation. + func cancel() {} +} + +@main +enum NetworkModuleRegression { + static func main() { + NWPathMonitor.scenario = CommandLine.arguments[1] + let module = NetworkModule() + if NWPathMonitor.scenario == "concurrent" { + DispatchQueue.concurrentPerform(iterations: 64) { _ in + let state = module.getNetworkStateAsync() + precondition(state["type"] as? String == "WIFI", "The first path must survive late callbacks") + precondition(state["isConnected"] as? Bool == true) + } + NWPathMonitor.deliveries.wait() + } else { + let state = module.getNetworkStateAsync() + let connected = NWPathMonitor.scenario != "offline" + precondition(state["type"] as? String == (connected ? "WIFI" : "NONE")) + precondition(state["isConnected"] as? Bool == connected) + precondition(state["isInternetReachable"] as? Bool == connected) + } + print("passed") + } +} diff --git a/apps/mobile/scripts/network-module.test.ts b/apps/mobile/scripts/network-module.test.ts new file mode 100644 index 000000000000..a304226c7f07 --- /dev/null +++ b/apps/mobile/scripts/network-module.test.ts @@ -0,0 +1,74 @@ +// @effect-diagnostics nodeBuiltinImport:off - Compiles and runs the native dependency regression directly. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- This native integration test uses the host Swift compiler. +describe.skipIf(NodeOS.platform() !== "darwin")("NetworkModule native path handoff", () => { + let directory: string; + let executable: string; + + beforeAll(() => { + directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-network-test-")); + executable = NodePath.join(directory, "network-regression"); + const source = NodeFS.readFileSync( + new URL("../node_modules/expo-network/ios/NetworkModule.swift", import.meta.url), + "utf8", + ); + // Compile the dependency's query and conversion methods, replacing only + // NWPathMonitor so we can deliver updates during and after cancellation. + const start = source.indexOf(" private func getNetworkPathAsync()"); + if (start === -1) throw new Error("NetworkModule query implementation not found"); + const methods = source + .slice(start, source.lastIndexOf("\n}")) + .replace("private func getNetworkStateAsync", "func getNetworkStateAsync"); + const module = NodePath.join(directory, "NetworkModule.swift"); + NodeFS.writeFileSync( + module, + `import Foundation\nfinal class NetworkModule {\n private let monitorQueue = DispatchQueue.global(qos: .default)\n${methods}\n}\n`, + ); + const types = NodePath.join(directory, "NetworkType.swift"); + NodeFS.writeFileSync( + types, + NodeFS.readFileSync( + new URL("../node_modules/expo-network/ios/NetworkType.swift", import.meta.url), + "utf8", + ).replace(/^import Network\n/m, ""), + ); + NodeChildProcess.execFileSync( + "swiftc", + [ + "-swift-version", + "5", + "-sanitize=thread", + module, + types, + NodeURL.fileURLToPath(new URL("./fixtures/NetworkModuleRegression.swift", import.meta.url)), + "-o", + executable, + ], + { timeout: 30_000, encoding: "utf8" }, + ); + }); + + afterAll(() => { + if (directory) NodeFS.rmSync(directory, { recursive: true, force: true }); + }); + + it.each([ + ["first", "keeps the first path when several updates arrive before the query resumes"], + ["offline", "reports a disconnected path"], + ["concurrent", "reads paths during overlapping queries and late callbacks without data races"], + ])("%s: %s", (name) => { + expect( + NodeChildProcess.execFileSync(executable, [name], { + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, TSAN_OPTIONS: "halt_on_error=1" }, + }).trim(), + ).toBe("passed"); + }); +}); diff --git a/patches/expo-network@58.0.3.patch b/patches/expo-network@58.0.3.patch new file mode 100644 index 000000000000..9dbbdbc31d0e --- /dev/null +++ b/patches/expo-network@58.0.3.patch @@ -0,0 +1,31 @@ +diff --git a/ios/NetworkModule.swift b/ios/NetworkModule.swift +--- a/ios/NetworkModule.swift ++++ b/ios/NetworkModule.swift +@@ -93,10 +93,18 @@ + // since we want to wait for the result to be updated once: + let tempMonitor = NWPathMonitor() + var tempPath: NWPath? ++ let pathLock = NSLock() + + let semaphore = DispatchSemaphore(value: 0) + + tempMonitor.pathUpdateHandler = { updatedPath in ++ // Cancellation does not drain queued updates. Publish only the first path ++ // and synchronize its retain with the caller reading it on another queue. ++ pathLock.lock() ++ defer { pathLock.unlock() } ++ guard tempPath == nil else { ++ return ++ } + tempPath = updatedPath + semaphore.signal() // Notify that we got the path + } +@@ -114,6 +122,8 @@ + return nil + } + ++ pathLock.lock() ++ defer { pathLock.unlock() } + return tempPath + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feb2eca59aac..6a8e06b7722e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,7 @@ patchedDependencies: alchemy@2.0.0-beta.80: bea1b6c0c0b23157fd5f439a1d7feff430f28ac1621ba135d8a13eeec60ccacd dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4 expo-audio@58.0.9: 2c2f8c73a23d48804c8b537b8cd73c25eb331aa47d82eaa481c6766cb81e5add + expo-network@58.0.3: cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237 expo-sharing@58.0.19: ad248759641ddd71b8331c09dbb3b18c8a551785f2df507533805babd3c0c124 expo-widgets@58.0.20: 8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c @@ -508,7 +509,7 @@ importers: version: 58.0.13(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) expo-network: specifier: ~58.0.3 - version: 58.0.3(expo@58.0.14)(react@19.3.0) + version: 58.0.3(patch_hash=cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237)(expo@58.0.14)(react@19.3.0) expo-notifications: specifier: ~58.1.5 version: 58.1.5(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)(typescript@7.0.2) @@ -19775,7 +19776,7 @@ snapshots: '@expo/modules-macros-win32-arm64': 0.20.0 '@expo/modules-macros-win32-x64': 0.20.0 - expo-network@58.0.3(expo@58.0.14)(react@19.3.0): + expo-network@58.0.3(patch_hash=cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237)(expo@58.0.14)(react@19.3.0): dependencies: expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) react: 19.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5800efc6c647..dbe0c9dedf0d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -322,6 +322,7 @@ patchedDependencies: micromark-extension-gfm-table@2.1.1: patches/micromark-extension-gfm-table@2.1.1.patch alchemy@2.0.0-beta.80: patches/alchemy@2.0.0-beta.80.patch expo-audio@58.0.9: patches/expo-audio@58.0.9.patch + expo-network@58.0.3: patches/expo-network@58.0.3.patch "@clerk/expo-native-components@0.2.0": patches/@clerk__expo-native-components@0.2.0.patch "@effect/vitest@4.0.5": patches/@effect__vitest@4.0.5.patch "@expo/metro-config@58.0.14": patches/@expo__metro-config@58.0.14.patch