diff --git a/.github/workflows/test-kmp.yml b/.github/workflows/test-kmp.yml index 62cf7911d773..a8c91f29eb2e 100644 --- a/.github/workflows/test-kmp.yml +++ b/.github/workflows/test-kmp.yml @@ -33,6 +33,11 @@ on: - 'packages/react-native/ReactCommon/react/utils/FloatComparison.h' - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' - 'packages/react-native/React/React-RCTFabric.podspec' + - 'packages/react-native/React-Core.podspec' + - 'packages/react-native/React/Base/RCTMultipartStreamReader.*' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt' + - 'packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m' - 'packages/react-native/scripts/react_native_pods.rb' - 'packages/react-native/scripts/cocoapods/kmp.rb' - 'packages/react-native/Package.swift' @@ -72,6 +77,11 @@ on: - 'packages/react-native/ReactCommon/react/utils/FloatComparison.h' - 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*' - 'packages/react-native/React/React-RCTFabric.podspec' + - 'packages/react-native/React-Core.podspec' + - 'packages/react-native/React/Base/RCTMultipartStreamReader.*' + - 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt' + - 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt' + - 'packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m' - 'packages/react-native/scripts/react_native_pods.rb' - 'packages/react-native/scripts/cocoapods/kmp.rb' - 'packages/react-native/Package.swift' @@ -92,6 +102,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -135,6 +149,12 @@ jobs: env: RCT_KMP_BUILD_TYPE: Release run: ./scripts/test-apple-gradient.sh + - name: Compare native and shared multipart adapters + if: ${{ !cancelled() && steps.shared.outcome == 'success' }} + working-directory: packages/react-native/ReactShared + run: | + ./scripts/test-apple-multipart.sh + python3 scripts/test-android-multipart.py --max-workers 2 - name: Test packaged XCFramework consumption if: ${{ !cancelled() && steps.shared.outcome == 'success' }} working-directory: packages/react-native/ReactShared @@ -159,6 +179,8 @@ jobs: path: | packages/react-native/ReactShared/build/reports/tests packages/react-native/ReactShared/build/test-results + packages/react-native/ReactShared/build/apple-multipart-test/**/*.log + packages/react-native/ReactShared/build/android-multipart-test/build/test-results packages/react-native/ReactShared/build/apple-distribution/**/*.json packages/react-native/ReactShared/build/apple-distribution/**/*.log packages/react-native/ReactShared/build/apple-distribution/**/consumer.xcresult diff --git a/packages/react-native/React-Core.podspec b/packages/react-native/React-Core.podspec index 4e5802e7035d..1e209d256306 100644 --- a/packages/react-native/React-Core.podspec +++ b/packages/react-native/React-Core.podspec @@ -7,6 +7,10 @@ require "json" package = JSON.parse(File.read(File.join(__dir__, "package.json"))) version = package['version'] +kmp_enabled = ENV['RCT_USE_KMP'] == '1' +if kmp_enabled && ENV['RCT_USE_PREBUILT_RNCORE'] != '0' + raise 'RCT_USE_KMP=1 requires React Native core source builds. Use use_react_native! or set RCT_USE_PREBUILT_RNCORE=0.' +end source = { :git => 'https://github.com/facebook/react-native.git' } if version == '1000.0.0' @@ -54,13 +58,25 @@ Pod::Spec.new do |s| s.compiler_flags = js_engine_flags() s.header_dir = "React" s.weak_framework = "JavaScriptCore" - s.pod_target_xcconfig = { + pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => header_search_paths, "DEFINES_MODULE" => "YES", "GCC_PREPROCESSOR_DEFINITIONS" => "RCT_METRO_PORT=${RCT_METRO_PORT}", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "FRAMEWORK_SEARCH_PATHS" => frameworks_search_paths.join(" ") } + if kmp_enabled + s.dependency 'React-KMP' + # React-Core is the common dependency of all Apple consumers. With dynamic + # pods it owns the Kotlin runtime once, even before Core calls a shared API. + # Static builds link the archive in the application via the post-install helper. + %w[iphoneos iphonesimulator].each do |sdk| + pod_target_xcconfig["GCC_PREPROCESSOR_DEFINITIONS[sdk=#{sdk}*]"] = '$(inherited) RCT_USE_KMP=1' + pod_target_xcconfig["FRAMEWORK_SEARCH_PATHS[sdk=#{sdk}*]"] = '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"' + pod_target_xcconfig["OTHER_LDFLAGS[sdk=#{sdk}*]"] = '$(inherited) -ObjC -framework ReactNativeShared' + end + end + s.pod_target_xcconfig = pod_target_xcconfig s.user_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\""} s.default_subspec = "Default" diff --git a/packages/react-native/React/Base/RCTMultipartStreamReader.m b/packages/react-native/React/Base/RCTMultipartStreamReader.m index a57b9ea944bf..0ab5a7b23a77 100644 --- a/packages/react-native/React/Base/RCTMultipartStreamReader.m +++ b/packages/react-native/React/Base/RCTMultipartStreamReader.m @@ -7,6 +7,14 @@ #import "RCTMultipartStreamReader.h" #import +#import + +#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST +#define RCT_MULTIPART_USE_KMP 1 +#import +#else +#define RCT_MULTIPART_USE_KMP 0 +#endif #define CRLF @"\r\n" @@ -30,6 +38,12 @@ - (NSDictionary *)parseHeaders:(NSData *)data { NSMutableDictionary *headers = [NSMutableDictionary new]; NSString *text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +#if RCT_MULTIPART_USE_KMP + for (RNSMultipartHeader *header in [RNSMultipartHeaders.shared parseText:text ?: @""]) { + NSString *value = [header.value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + [headers setValue:value forKey:header.name]; + } +#else NSArray *lines = [text componentsSeparatedByString:CRLF]; for (NSString *line in lines) { NSUInteger location = [line rangeOfString:@":"].location; @@ -41,6 +55,7 @@ - (NSDictionary *)parseHeaders:(NSData *)data stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; [headers setValue:value forKey:key]; } +#endif return headers; } @@ -84,13 +99,17 @@ - (void)emitProgress:(NSDictionary *)headers - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback progressCallback:(RCTMultipartProgressCallback)progressCallback { - NSInteger chunkStart = 0; - NSInteger bytesSeen = 0; - NSData *delimiter = [[NSString stringWithFormat:@"%@--%@%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding]; NSData *closeDelimiter = [[NSString stringWithFormat:@"%@--%@--%@", CRLF, _boundary, CRLF] dataUsingEncoding:NSUTF8StringEncoding]; +#if RCT_MULTIPART_USE_KMP + RNSMultipartFraming *framing = [[RNSMultipartFraming alloc] initWithDelimiterLength:(int32_t)delimiter.length + closeDelimiterLength:(int32_t)closeDelimiter.length]; +#else + NSInteger chunkStart = 0; + NSInteger bytesSeen = 0; +#endif NSMutableData *content = [[NSMutableData alloc] initWithCapacity:1]; NSDictionary *currentHeaders = nil; NSUInteger currentHeadersLength = 0; @@ -101,9 +120,13 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback [_stream open]; while (true) { BOOL isCloseDelimiter = NO; - // Search only a subset of chunk that we haven't seen before + few bytes - // to allow for the edge case when the delimiter is cut by read call +#if RCT_MULTIPART_USE_KMP + NSInteger searchStart = [framing searchStartBufferOffset:0]; + NSInteger chunkStart = [framing partStartBufferOffset:0]; +#else + // Preserve overlap when a delimiter is split between reads. NSInteger searchStart = MAX(bytesSeen - (NSInteger)closeDelimiter.length, chunkStart); +#endif NSRange remainingBufferRange = NSMakeRange(searchStart, content.length - searchStart); // Check for delimiters. @@ -113,6 +136,14 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback range = [content rangeOfData:closeDelimiter options:0 range:remainingBufferRange]; } +#if RCT_MULTIPART_USE_KMP + NSInteger index = range.location == NSNotFound ? -1 : (NSInteger)range.location; + RNSMultipartChunk *chunk = [framing nextChunkBufferLength:content.length + bufferOffset:0 + delimiterIndex:isCloseDelimiter ? -1 : index + closeDelimiterIndex:isCloseDelimiter ? index : -1]; +#endif + if (range.location == NSNotFound) { if (currentHeaders == nil) { // Check for the headers delimiter. @@ -131,7 +162,9 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback callback:progressCallback]; } +#if !RCT_MULTIPART_USE_KMP bytesSeen = content.length; +#endif NSInteger bytesRead = [_stream read:buffer maxLength:bufferLen]; if (bytesRead <= 0 || _stream.streamError) { return NO; @@ -140,12 +173,19 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback continue; } +#if RCT_MULTIPART_USE_KMP + NSInteger chunkEnd = chunk.end; + BOOL isPart = chunk.isPart; + isCloseDelimiter = chunk.isLast; +#else NSInteger chunkEnd = range.location; - NSInteger length = chunkEnd - chunkStart; + BOOL isPart = chunkStart > 0; bytesSeen = chunkEnd; +#endif + NSInteger length = chunkEnd - chunkStart; // Ignore preamble - if (chunkStart > 0) { + if (isPart) { NSData *chunk = [content subdataWithRange:NSMakeRange(chunkStart, length)]; [self emitProgress:currentHeaders contentLength:chunk.length - currentHeadersLength @@ -160,7 +200,9 @@ - (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback return YES; } +#if !RCT_MULTIPART_USE_KMP chunkStart = chunkEnd + delimiter.length; +#endif } } diff --git a/packages/react-native/React/React-RCTFabric.podspec b/packages/react-native/React/React-RCTFabric.podspec index e196aae8fdf0..92b4de666d5b 100644 --- a/packages/react-native/React/React-RCTFabric.podspec +++ b/packages/react-native/React/React-RCTFabric.podspec @@ -69,8 +69,6 @@ Pod::Spec.new do |s| 'GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]' => '$(inherited) RCT_USE_KMP=1', 'FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', 'FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]' => '$(inherited) "$(PODS_CONFIGURATION_BUILD_DIR)/ReactNativeSharedKMP"', - 'OTHER_LDFLAGS[sdk=iphoneos*]' => '$(inherited) -framework ReactNativeShared', - 'OTHER_LDFLAGS[sdk=iphonesimulator*]' => '$(inherited) -framework ReactNativeShared', }) end s.pod_target_xcconfig = pod_target_xcconfig diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt index a49f2c98339e..8e7eb1173ff8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt @@ -9,6 +9,8 @@ package com.facebook.react.devsupport +import com.facebook.react.shared.MultipartFraming +import com.facebook.react.shared.MultipartHeaders import java.io.IOException import java.util.TreeMap import kotlin.math.max @@ -52,30 +54,22 @@ internal class MultipartStreamReader( // throughput and fewer syscalls. For a 2MB bundle: ~128 reads instead of ~512. // Memory impact is negligible (12KB increase) while I/O overhead is significantly reduced. val bufferLen = 16 * 1024 - var chunkStart: Long = 0 - var bytesSeen: Long = 0 + val framing = MultipartFraming(delimiter.size(), closeDelimiter.size()) + var bufferOffset = 0L val content = Buffer() var currentHeaders: Map? = null var currentBodyStartIndexInContent: Long = -1 while (true) { - var isCloseDelimiter = false - - // Search only a subset of chunk that we haven't seen before + few bytes - // to allow for the edge case when the delimiter is cut by read call. - val searchStart = - max((bytesSeen - closeDelimiter.size()).toDouble(), chunkStart.toDouble()).toLong() - - var indexOfDelimiter = content.indexOf(delimiter, searchStart) - if (indexOfDelimiter == -1L) { - isCloseDelimiter = true - indexOfDelimiter = content.indexOf(closeDelimiter, searchStart) - } - - if (indexOfDelimiter == -1L) { - bytesSeen = content.size() - + val searchStart = framing.searchStart(bufferOffset) + val indexOfDelimiter = content.indexOf(delimiter, searchStart) + val indexOfCloseDelimiter = + if (indexOfDelimiter < 0) content.indexOf(closeDelimiter, searchStart) else -1L + val chunk = + framing.nextChunk(content.size(), bufferOffset, indexOfDelimiter, indexOfCloseDelimiter) + + if (chunk == null) { if (currentHeaders == null) { val indexOfHeadersDelimiter = content.indexOf(headersDelimiter, searchStart) if (indexOfHeadersDelimiter >= 0) { @@ -97,29 +91,27 @@ internal class MultipartStreamReader( continue } - val chunkEnd = indexOfDelimiter - val length = chunkEnd - chunkStart + val chunkEnd = chunk.end + val length = chunkEnd - chunk.start // Ignore preamble - if (chunkStart > 0) { + if (chunk.isPart) { if (currentHeaders != null && currentBodyStartIndexInContent >= 0) { val loadedFinal = max(0L, chunkEnd - currentBodyStartIndexInContent) emitProgress(currentHeaders, loadedFinal, true, listener) } - content.skip(chunkStart) - emitChunk(content, length, isCloseDelimiter, listener) + content.skip(chunk.start) + emitChunk(content, length, chunk.isLast, listener) currentHeaders = null currentBodyStartIndexInContent = -1 } else { content.skip(chunkEnd) } - if (isCloseDelimiter) { + if (chunk.isLast) { return true } - - chunkStart = delimiter.size().toLong() - bytesSeen = chunkStart + bufferOffset += chunkEnd } } @@ -127,14 +119,9 @@ internal class MultipartStreamReader( // Header names are case-insensitive val headers: MutableMap = TreeMap(String.CASE_INSENSITIVE_ORDER) val text = data.readUtf8() - val lines = text.split(CRLF.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - for (line in lines) { - val indexOfSeparator = line.indexOf(":") - if (indexOfSeparator == -1) { - continue - } - val key = line.substring(0, indexOfSeparator).trim { it <= ' ' } - val value = line.substring(indexOfSeparator + 1).trim { it <= ' ' } + for (header in MultipartHeaders.parse(text)) { + val key = header.name.trim { it <= ' ' } + val value = header.value.trim { it <= ' ' } headers[key] = value } return headers diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt index 8ede630a48a9..84d227da0fa3 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt @@ -251,6 +251,56 @@ class MultipartStreamReaderTest { } } + @Test + fun testHeaderWhitespaceDuplicatesAndColonValues() { + val source = + Buffer() + .writeUtf8( + "\r\n--sample\r\n X-Name : first\r\nx-name: second:extra \r\ninvalid\r\n\r\nbody\r\n--sample--\r\n" + ) + var calls = 0 + assertThat( + MultipartStreamReader(source, "sample") + .readAllParts( + object : CallCountTrackingChunkCallback() { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) { + calls++ + assertThat(headers).hasSize(1) + assertThat(headers["X-Name"]).isEqualTo("second:extra") + assertThat(body.readUtf8()).isEqualTo("body") + assertThat(isLastChunk).isTrue() + } + } + ) + ) + .isTrue() + assertThat(calls).isEqualTo(1) + } + + @Test + fun testFinalProgressForFragmentedBody() { + val body = "x".repeat(64 * 1024) + val source = + Buffer() + .writeUtf8( + "\r\n--sample\r\nContent-Length: ${body.length}\r\n\r\n$body\r\n--sample--\r\n" + ) + val progress = mutableListOf>() + val callback = + object : CallCountTrackingChunkCallback() { + override fun onChunkProgress(headers: Map, loaded: Long, total: Long) { + progress.add(loaded to total) + } + } + assertThat(MultipartStreamReader(source, "sample").readAllParts(callback)).isTrue() + assertThat(callback.callCount).isEqualTo(1) + assertThat(progress.last()).isEqualTo(body.length.toLong() to body.length.toLong()) + } + internal open class CallCountTrackingChunkCallback : MultipartStreamReader.ChunkListener { var callCount = 0 private set diff --git a/packages/react-native/ReactShared/README.md b/packages/react-native/ReactShared/README.md index d415329e1ce8..3ca57609f314 100644 --- a/packages/react-native/ReactShared/README.md +++ b/packages/react-native/ReactShared/README.md @@ -1,11 +1,11 @@ -# React Native shared Kotlin gradient pilot +# React Native shared Kotlin experiments -This is the gradient use case layered on the standalone KMP foundation. The +These use cases build on the standalone KMP foundation. The foundation's unpublished compiler/interop fixture remains available with `-PreactNativeSharedSmoke=true`; its classes and reports stay under `build/smoke` and are excluded from normal shared outputs. Run `./scripts/test-apple-smoke.sh` to check that fixture through Objective-C. -The gradient implementation is the only production use case in this change. +Each use case has a separate behavior and performance review. This module shares CSS gradient stop position and transition-hint calculations between Android and iOS using Kotlin Multiplatform. It has no Compose dependency. @@ -17,6 +17,34 @@ source color indices, and interpolation weights. Platform adapters retain their existing tolerance, logarithm precision, and color-space behavior. The module does not depend on ReactAndroid, React-Core, UIKit, JNI, or the C++ renderer. +## Multipart framing + +`MultipartFraming` shares delimiter overlap, preamble and completed-part state +between Android's sliding Okio buffer and Apple's retained NSData buffer. +`MultipartHeaders` splits raw header fields; each platform keeps its existing +whitespace and key-comparison rules. Native code retains buffer searches, stream +and body ownership, callbacks and progress timing. Body bytes never cross the +Kotlin/Objective-C boundary. + +Run `./scripts/test-apple-multipart.sh` and +`python3 scripts/test-android-multipart.py` for the actual adapters' tests from a +repository checkout; their Android and RNTester test sources are not distributed +in the npm package. The Android runner requires Python 3.11 or later and uses the +repository's Okio, AssertJ, JUnit and Kotlin standard-library versions. Its newer +standalone compiler retains ReactAndroid's Kotlin language and API level. +The Apple runner also compares exact callbacks/body bytes against the native +fallback and checks Catalyst. Set `RCT_KMP_BENCHMARK=1` for its optional 2–20 MiB +parser benchmark. The Android runner supports `--baseline-ref` with an explicit +native parser revision and `--benchmark`; see `--help`. Its timings and allocation +counters describe a host JVM, not Android ART or network download throughput. +Both probes exclude constructing their known input payloads. + +This use case can amortize interop over buffer reads. A C++ implementation could +also share decisions and directly search native buffers, but would add an Android +JNI interface to this currently Kotlin/Objective-C utility. Neither approach +removes platform I/O or Catalyst fallback. Measure application memory and real +bundle-download behavior before broadening adoption. + ## Build and test The standalone build uses its own Gradle wrapper and Kotlin plugin so that it @@ -70,7 +98,7 @@ repository, packs the npm sources, and builds a fresh Android application for each dependency route. It checks dependency resolution, shared-class uniqueness, Debug packaging and Release shrinking. See `--help` for emulator execution and fixture preparation options. These small consumers exercise the real Android -gradient adapter; they do not replace RNTester coverage. +gradient and multipart adapters; they do not replace RNTester coverage. ## Apple opt-in @@ -86,6 +114,14 @@ builds. During the Xcode build, the support pod builds the shared static framewo for the current SDK, architecture, and configuration. The application still uses its existing Objective-C++ and UIKit rendering code. +All Apple consumers use one shared Kotlin runtime. With dynamic CocoaPods +frameworks, `React-Core` owns the static Kotlin archive and exports its Objective-C +classes to dependent pods such as Fabric. The archive is explicitly loaded so the +owner does not depend on which shared algorithm it happens to call. With static +libraries or static frameworks, the application owns the archive instead. Hosted +tests inherit their host's runtime. Adding an algorithm must not add another +framework link to its consuming pod. + For a custom Xcode configuration name that does not contain `Debug` or `Release`, set the `RCT_KMP_BUILD_TYPE` build setting to `Debug` or `Release`. @@ -123,8 +159,8 @@ python3 scripts/benchmark-kmp-build.py ``` The app script copies RNTester into an isolated sibling directory, enables KMP, -and verifies the actual compiled gradient adapter. Simulator runs execute the -RNTester test plan and launch the app; device and Catalyst runs are unsigned +and verifies the actual compiled gradient and multipart adapters. Simulator runs +execute the RNTester test plan and launch the app; device and Catalyst runs are unsigned build checks. Catalyst is enabled in the copied Podfile and application project. Each run requires fresh build outputs. It preserves RNTester's existing hosted-test topology and removes the temporary app and any test-owned servers and simulator when it finishes. diff --git a/packages/react-native/ReactShared/React-KMP.podspec b/packages/react-native/ReactShared/React-KMP.podspec index a09a4fe6e026..9a36d38ba47f 100644 --- a/packages/react-native/ReactShared/React-KMP.podspec +++ b/packages/react-native/ReactShared/React-KMP.podspec @@ -10,7 +10,7 @@ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) Pod::Spec.new do |s| s.name = 'React-KMP' s.version = package['version'] - s.summary = 'Opt-in shared Kotlin gradient algorithms for React Native.' + s.summary = 'Opt-in shared Kotlin algorithms for React Native.' s.homepage = 'https://reactnative.dev/' s.license = package['license'] s.author = 'Meta Platforms, Inc. and its affiliates' @@ -28,7 +28,7 @@ Pod::Spec.new do |s| # Link flags are added to direct consumers in react_native_post_install. # user_target_xcconfig also reaches tests that inherit only search paths. s.script_phase = { - :name => 'Build shared Kotlin gradient framework', + :name => 'Build shared Kotlin framework', :execution_position => :before_compile, :always_out_of_date => '1', :script => '"${PODS_TARGET_SRCROOT}/scripts/build-apple-framework.sh"', diff --git a/packages/react-native/ReactShared/scripts/test-android-consumers.py b/packages/react-native/ReactShared/scripts/test-android-consumers.py index ff1512df9ac4..0dd596b1974b 100644 --- a/packages/react-native/ReactShared/scripts/test-android-consumers.py +++ b/packages/react-native/ReactShared/scripts/test-android-consumers.py @@ -92,23 +92,31 @@ def inspect_aar(aar): if name.startswith("com/facebook/react/shared/") and name.endswith(".class")) required = {f"com/facebook/react/shared/{name}.class" - for name in ["GradientStops", "GradientStopInput", "ResolvedGradientStop"]} + for name in ["GradientStops", "GradientStopInput", "ResolvedGradientStop", + "MultipartFraming", "MultipartChunk", "MultipartHeaders", "MultipartHeader"]} if not required.issubset(classes) or any(count != 1 for count in classes.values()): raise AssertionError(f"Missing or duplicated shared classes in {aar}: {classes}") with tempfile.TemporaryDirectory(prefix="react-native-kmp-bytecode-") as directory: jar = Path(directory) / "classes.jar" with zipfile.ZipFile(aar) as archive: jar.write_bytes(archive.read("classes.jar")) - bytecode = subprocess.check_output( - ["javap", "-c", "-p", "-classpath", str(jar), - "com.facebook.react.uimanager.style.ColorStopUtils"], text=True) - if not re.search(r"invoke(?:static|virtual)\s+.*// Method com/facebook/react/shared/GradientStops\.resolve(?:\$default)?:", bytecode): - raise AssertionError(f"The Android adapter does not invoke shared GradientStops in {aar}") + adapters = { + "com.facebook.react.uimanager.style.ColorStopUtils": ("GradientStops.resolve",), + "com.facebook.react.devsupport.MultipartStreamReader": ( + "MultipartFraming.nextChunk", "MultipartHeaders.parse"), + } + for adapter, methods in adapters.items(): + bytecode = subprocess.check_output( + ["javap", "-c", "-p", "-classpath", str(jar), adapter], text=True) + for method in methods: + pattern = r"invoke(?:static|virtual)\s+.*// Method com/facebook/react/shared/" + re.escape(method) + r"(?:\$default)?:" + if not re.search(pattern, bytecode): + raise AssertionError(f"{adapter} does not invoke shared {method} in {aar}") return {"path": str(aar), "sha256": hashlib.sha256(aar.read_bytes()).hexdigest(), "shared_class_counts": dict(classes), "adapter_invokes_shared_resolver": True} -ACTIVITY = """ +ACTIVITY = r""" package com.facebook.react.kmp.consumer; import android.app.Activity; @@ -117,6 +125,7 @@ def inspect_aar(aar): import android.util.DisplayMetrics; import android.util.Log; import android.widget.TextView; +import com.facebook.react.devsupport.MultipartStreamReader; import com.facebook.react.uimanager.DisplayMetricsHolder; import com.facebook.react.uimanager.LengthPercentage; import com.facebook.react.uimanager.LengthPercentageType; @@ -125,6 +134,10 @@ def inspect_aar(aar): import com.facebook.react.uimanager.style.ProcessedColorStop; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.io.IOException; +import okio.Buffer; +import okio.BufferedSource; // Java deliberately exercises the packaged Android adapter through its JVM API. // No shared source files or replacement implementation are compiled into this app. @@ -148,6 +161,7 @@ def inspect_aar(aar): || hint.get(3).getColor() != Color.argb(191, 127, 0, 127)) { throw new AssertionError("Shared hint expansion or Android alpha rounding"); } + checkMultipart(); String result = "KMP consumer PASS " + BuildConfig.CONSUMER_MODE + " " + getIntent().getStringExtra("validationToken"); TextView text = new TextView(this); @@ -155,6 +169,36 @@ def inspect_aar(aar): setContentView(text); Log.i("KmpConsumer", result); } + + private static void checkMultipart() { + // The first body ends in CRLF, so a header marker can straddle the boundary. + Buffer input = new Buffer().writeUtf8( + "\r\n--sample\r\nfirst\r\n\r\n--sample\r\n" + + "content-type: text/plain\r\n\r\nsecond\r\n--sample--\r\n"); + int[] parts = {0}; + try { + boolean complete = new MultipartStreamReader(input, "sample").readAllParts( + new MultipartStreamReader.ChunkListener() { + @Override public void onChunkComplete( + Map headers, BufferedSource body, boolean last) throws IOException { + int index = parts[0]++; + String expected = index == 0 ? "first\r\n" : "second"; + if (index > 1 || !expected.equals(body.readUtf8()) || last != (index == 1)) { + throw new AssertionError("Shared multipart body or completion"); + } + if (index == 0 ? !headers.isEmpty() + : !"text/plain".equals(headers.get("CONTENT-TYPE"))) { + throw new AssertionError("Android multipart header policy"); + } + } + @Override public void onChunkProgress( + Map headers, long loaded, long total) {} + }); + if (!complete || parts[0] != 2) throw new AssertionError("Shared multipart framing"); + } catch (IOException error) { + throw new AssertionError(error); + } + } } """ diff --git a/packages/react-native/ReactShared/scripts/test-android-multipart.py b/packages/react-native/ReactShared/scripts/test-android-multipart.py new file mode 100755 index 000000000000..54dd21744f73 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-android-multipart.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +"""Run the real Android multipart adapter's JVM tests without building ReactAndroid. + +An optional benchmark compares the adapter with an explicitly selected native Git +baseline. This measures a host JVM, not Android ART or network throughput. +""" + +import argparse +import json +import pathlib +import re +import shutil +import subprocess +import tomllib + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=pathlib.Path) + parser.add_argument("--baseline-ref", help="Git revision of the native adapter") + parser.add_argument("--benchmark", action="store_true") + parser.add_argument("--offline", action="store_true") + parser.add_argument("--max-workers", type=int, default=2) + args = parser.parse_args() + if args.benchmark and not args.baseline_ref: + parser.error("--benchmark requires an explicit --baseline-ref") + + shared = pathlib.Path(__file__).resolve().parents[1] + react_native = shared.parent + repo = react_native.parents[1] + adapter = react_native / "ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt" + tests = react_native / "ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt" + if not tests.is_file(): + parser.error("This fixture requires a React Native repository checkout; Android test sources are not included in the npm package.") + versions = tomllib.loads((react_native / "gradle/libs.versions.toml").read_text())["versions"] + language_version = ".".join(versions["kotlin"].split(".")[:2]) + output = (args.output or shared / "build/android-multipart-test").resolve() + output.mkdir(parents=True, exist_ok=True) + gradle = [str(shared / "gradlew"), "--console=plain", f"--max-workers={args.max_workers}"] + if args.offline: + gradle.append("--offline") + subprocess.run(gradle + ["-p", str(shared), "exportAndroidJar"], check=True) + + version = re.search(r'kotlin\("multiplatform"\) version "([^"]+)"', (shared / "build.gradle.kts").read_text()) + if not version: + raise RuntimeError("Could not find the shared module's Kotlin compiler version") + (output / "settings.gradle.kts").write_text(''' +pluginManagement { + resolutionStrategy { eachPlugin { + if (requested.id.id == "org.jetbrains.kotlin.jvm") + useModule("org.jetbrains.kotlin:kotlin-gradle-plugin:${requested.version}") + } } + repositories { mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { repositories { mavenCentral() } } +rootProject.name = "multipart-adapter-tests" +''') + # Keep the standalone compiler compatible with its Gradle wrapper while + # matching ReactAndroid's language/API level and runtime dependencies. + (output / "gradle.properties").write_text("kotlin.stdlib.default.dependency=false\n") + (output / "build.gradle.kts").write_text(''' +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { kotlin("jvm") version %s } +kotlin { + jvmToolchain(17) + compilerOptions { + languageVersion.set(KotlinVersion.fromVersion(%s)) + apiVersion.set(KotlinVersion.fromVersion(%s)) + } +} +sourceSets { + main { kotlin.srcDir("main") } + test { kotlin.srcDir("test") } +} +dependencies { + implementation(files(%s)) + implementation("org.jetbrains.kotlin:kotlin-stdlib:%s") + implementation("com.squareup.okio:okio:%s") + testImplementation("junit:junit:%s") + testImplementation("org.assertj:assertj-core:%s") +} +tasks.test { testLogging { events("passed", "failed", "skipped") } } +tasks.register("benchmark") { + classpath = sourceSets.main.get().runtimeClasspath + mainClass.set("com.facebook.react.devsupport.AndroidMultipartBenchmarkKt") +} +''' % (json.dumps(version[1]), json.dumps(language_version), json.dumps(language_version), + json.dumps(str(shared / "build/android/react-native-shared.jar")), + versions["kotlin"], versions["okio"], versions["junit"], versions["assertj"])) + + # Keep only this runner's generated fixture inputs when reusing an output path. + generated = { + "main": ("MultipartStreamReader.kt", "MultipartStreamReaderBaseline.kt", "AndroidMultipartBenchmark.kt"), + "test": ("MultipartStreamReaderTest.kt", "MultipartStreamReaderBaselineTest.kt"), + } + for name, files in generated.items(): + directory = output / name + directory.mkdir(exist_ok=True) + for source in files: + (directory / source).unlink(missing_ok=True) + shutil.copyfile(adapter, output / "main" / adapter.name) + shutil.copyfile(tests, output / "test" / tests.name) + if args.baseline_ref: + baseline_commit = subprocess.check_output( + ["git", "rev-parse", "--verify", f"{args.baseline_ref}^{{commit}}"], cwd=repo, text=True + ).strip() + print(f"Native baseline: {baseline_commit}", flush=True) + baseline = subprocess.check_output( + ["git", "show", f"{baseline_commit}:{adapter.relative_to(repo)}"], cwd=repo, text=True + ) + if "com.facebook.react.shared" in baseline: + raise RuntimeError("The selected baseline already uses shared Kotlin code") + (output / "main/MultipartStreamReaderBaseline.kt").write_text( + baseline.replace("MultipartStreamReader", "MultipartStreamReaderBaseline") + ) + (output / "test/MultipartStreamReaderBaselineTest.kt").write_text( + tests.read_text().replace("MultipartStreamReader", "MultipartStreamReaderBaseline") + ) + if args.benchmark: + shutil.copyfile(shared / "tests/AndroidMultipartBenchmark.kt", output / "main/AndroidMultipartBenchmark.kt") + subprocess.run(gradle + ["-p", str(output), "test"], check=True) + if args.benchmark: + # Keep test compilation/execution out of the measurement window. + subprocess.run(gradle + ["-p", str(output), "benchmark"], check=True) + + +if __name__ == "__main__": + main() diff --git a/packages/react-native/ReactShared/scripts/test-apple-app.sh b/packages/react-native/ReactShared/scripts/test-apple-app.sh index 8e25ef0c0ad5..20b314937614 100755 --- a/packages/react-native/ReactShared/scripts/test-apple-app.sh +++ b/packages/react-native/ReactShared/scripts/test-apple-app.sh @@ -197,20 +197,27 @@ else fi # Check the actual object compiled by the pod target: just finding Kotlin in the -# app would not prove that RCTGradientUtils selected the shared implementation. -python3 - "$output/DerivedData" "$platform" "$output/gradient-symbols.txt" <<'PY' +# app would not prove that each adapter selected the shared implementation. +python3 - "$output/DerivedData" "$platform" "$output/shared-adapter-symbols.txt" <<'PY' import pathlib import subprocess import sys -objects = list(pathlib.Path(sys.argv[1]).rglob('RCTGradientUtils.o')) -if not objects: - sys.exit('error: The RNTester build did not compile the gradient adapter from source.') -symbols = [subprocess.check_output(['xcrun', 'nm', '-u', str(item)], text=True) for item in objects] -pathlib.Path(sys.argv[3]).write_text('\n'.join(symbols)) expected_kmp = sys.argv[2] != 'catalyst' -if any(('OBJC_CLASS_$_RNSGradientStops' in item) != expected_kmp for item in symbols): - sys.exit('error: Compiled gradient adapter selected an unexpected KMP/native implementation.') +reports = [] +for filename, classes in ( + ('RCTGradientUtils.o', ('RNSGradientStops',)), + ('RCTMultipartStreamReader.o', ('RNSMultipartFraming', 'RNSMultipartHeaders')), +): + objects = list(pathlib.Path(sys.argv[1]).rglob(filename)) + if not objects: + sys.exit(f'error: RNTester did not compile {filename} from source.') + symbols = [subprocess.check_output(['xcrun', 'nm', '-u', str(item)], text=True) for item in objects] + reports.extend([filename, *symbols]) + for class_name in classes: + if any((f'OBJC_CLASS_$_{class_name}' in item) != expected_kmp for item in symbols): + sys.exit(f'error: {filename} selected an unexpected implementation for {class_name}.') +pathlib.Path(sys.argv[3]).write_text('\n'.join(reports)) PY if [[ "$platform" == simulator ]]; then # Hosted test bundles must resolve Kotlin classes through their app, even when diff --git a/packages/react-native/ReactShared/scripts/test-apple-multipart.sh b/packages/react-native/ReactShared/scripts/test-apple-multipart.sh new file mode 100755 index 000000000000..ce6bce09acb2 --- /dev/null +++ b/packages/react-native/ReactShared/scripts/test-apple-multipart.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +set -euo pipefail + +shared_root="$(cd "$(dirname "$0")/.." && pwd)" +react_native_root="$(cd "$shared_root/.." && pwd)" +repo_root="$(cd "$react_native_root/../.." && pwd)" +tests="$repo_root/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m" +if [[ ! -f "$tests" ]]; then + echo 'error: This fixture requires a React Native repository checkout with RNTester test sources.' >&2 + exit 1 +fi +test_root="${RCT_KMP_TEST_OUTPUT_DIR:-$shared_root/build/apple-multipart-test}" +architecture="$(uname -m)" +sdk_root="$(xcrun --sdk iphonesimulator --show-sdk-path)" +developer="$(xcode-select -p)/Platforms/iPhoneSimulator.platform/Developer" +mkdir -p "$test_root/include/React" +ln -sf "$react_native_root/React/Base/RCTMultipartStreamReader.h" "$test_root/include/React/RCTMultipartStreamReader.h" + +PLATFORM_NAME=iphonesimulator ARCHS="$architecture" CONFIGURATION=Release \ + PODS_CONFIGURATION_BUILD_DIR="$test_root" "$shared_root/scripts/build-apple-framework.sh" + +flags=( + -fobjc-arc -O2 -target "$architecture-apple-ios15.1-simulator" -isysroot "$sdk_root" + -I "$test_root/include" -F "$test_root/ReactNativeSharedKMP" +) +adapter="$react_native_root/React/Base/RCTMultipartStreamReader.m" +xcrun clang "${flags[@]}" -DRCT_USE_KMP=1 -c "$adapter" -o "$test_root/adapter.o" +xcrun clang "${flags[@]}" -DRCT_USE_KMP=0 -DRCTMultipartStreamReader=RCTMultipartStreamReaderBaseline \ + -c "$adapter" -o "$test_root/baseline.o" + +# Check that the opt-in actually calls the shared framing/header types. +nm -u "$test_root/adapter.o" | grep -q 'OBJC_CLASS_\$_RNSMultipartFraming' +nm -u "$test_root/adapter.o" | grep -q 'OBJC_CLASS_\$_RNSMultipartHeaders' +if nm -u "$test_root/baseline.o" | grep -q 'OBJC_CLASS_\$_RNS'; then + echo 'error: Native fallback unexpectedly references Kotlin classes.' >&2 + exit 1 +fi + +# Compile the actual RNTester XCTest source, then run the same tests on both paths. +for mode in native kmp; do + bundle="$test_root/Multipart-$mode.xctest" + mkdir -p "$bundle" + use_kmp=0 + if [[ "$mode" == kmp ]]; then use_kmp=1; fi + xcrun clang "${flags[@]}" -DRCT_USE_KMP="$use_kmp" -bundle \ + -F "$developer/Library/Frameworks" -framework XCTest \ + -Wl,-rpath,"$developer/Library/Frameworks" \ + "$adapter" "$tests" \ + -framework Foundation -framework QuartzCore -framework ReactNativeShared -o "$bundle/MultipartTests" + /usr/libexec/PlistBuddy -c Clear "$bundle/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c 'Add :CFBundleExecutable string MultipartTests' "$bundle/Info.plist" + /usr/libexec/PlistBuddy -c 'Add :CFBundleIdentifier string com.facebook.react.MultipartTests' "$bundle/Info.plist" + /usr/libexec/PlistBuddy -c 'Add :CFBundlePackageType string BNDL' "$bundle/Info.plist" +done + +xcrun clang "${flags[@]}" "$shared_root/tests/AppleMultipartParity.m" \ + "$test_root/adapter.o" "$test_root/baseline.o" \ + -framework Foundation -framework QuartzCore -framework ReactNativeShared -o "$test_root/AppleMultipartParity" + +# Catalyst keeps the Foundation implementation, including when opt-in is set. +mac_sdk_root="$(xcrun --sdk macosx --show-sdk-path)" +xcrun clang -fobjc-arc -DRCT_USE_KMP=1 \ + -target "$architecture-apple-ios15.1-macabi" -isysroot "$mac_sdk_root" \ + -isystem "$mac_sdk_root/System/iOSSupport/usr/include" \ + -iframework "$mac_sdk_root/System/iOSSupport/System/Library/Frameworks" \ + -fsyntax-only "$adapter" + +if [[ "${RCT_KMP_BUILD_ONLY:-0}" == "1" ]]; then exit 0; fi +simulator="${RCT_KMP_SIMULATOR_UDID:-}" +if [[ -z "$simulator" ]]; then + simulator="$(xcrun simctl list devices available -j | python3 -c ' +import json, sys +for runtime, entries in json.load(sys.stdin)["devices"].items(): + if ".iOS-" in runtime and entries: + print(entries[0]["udid"]) + break +')" +fi +if [[ -z "$simulator" ]]; then + echo 'error: Install an iOS Simulator runtime before running multipart tests.' >&2 + exit 1 +fi +for mode in native kmp; do + xcrun simctl spawn --standalone "$simulator" "$developer/Library/Xcode/Agents/xctest" \ + "$test_root/Multipart-$mode.xctest" +done +xcrun simctl spawn --standalone "$simulator" "$test_root/AppleMultipartParity" +if [[ "${RCT_KMP_BENCHMARK:-0}" == "1" ]]; then + xcrun simctl spawn --standalone "$simulator" "$test_root/AppleMultipartParity" --benchmark +fi diff --git a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb index 1d5b95f29049..a84e91918087 100644 --- a/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb +++ b/packages/react-native/ReactShared/scripts/test-cocoapods-linking.rb @@ -23,6 +23,7 @@ FileUtils.mkdir_p(fixture) project = Xcodeproj::Project.new(File.join(fixture, 'Fixture.xcodeproj')) host = project.new_target(:application, 'Host', :ios, '15.1') + project.new_target(:application, 'CoreOnly', :ios, '15.1') hosted = project.new_target(:unit_test_bundle, 'HostedTests', :ios, '15.1') sibling = project.new_target(:unit_test_bundle, 'SiblingHostedTests', :ios, '15.1') without_metadata = project.new_target(:unit_test_bundle, 'SiblingWithoutMetadata', :ios, '15.1') @@ -82,7 +83,7 @@ end project.save - %w[React-RCTFabric TestSupport].each do |pod_name| + %w[React-Core React-RCTFabric TestSupport].each do |pod_name| pod_dir = File.join(fixture, pod_name) FileUtils.mkdir_p(pod_dir) File.write(File.join(pod_dir, 'Fixture.m'), "#import \n") @@ -97,7 +98,8 @@ s.source = { :git => 'https://example.invalid/fixture.git' } s.platform = :ios, '15.1' s.source_files = 'Fixture.m' - #{"s.dependency 'React-KMP'" if pod_name == 'React-RCTFabric'} + #{"s.dependency 'React-KMP'" if pod_name == 'React-Core'} + #{"s.dependency 'React-Core'" if pod_name == 'React-RCTFabric'} end PODSPEC end @@ -117,6 +119,7 @@ def min_supported_versions target 'Host' do #{"use_frameworks! :linkage => :dynamic" if linkage == 'mixed'} pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-Core', :path => './React-Core' pod 'React-RCTFabric', :path => './React-RCTFabric' target 'HostedTests' do inherit! :search_paths @@ -129,10 +132,16 @@ def min_supported_versions target #{target.dump} do #{'use_frameworks! :linkage => :static' if linkage == 'mixed'} pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-Core', :path => './React-Core' pod 'React-RCTFabric', :path => './React-RCTFabric' end TARGET end.join} + target 'CoreOnly' do + #{'use_frameworks! :linkage => :static' if linkage == 'mixed'} + pod 'React-KMP', :path => #{shared_root.dump} + pod 'React-Core', :path => './React-Core' + end target 'PlainHost' do pod 'TestSupport', :path => './TestSupport' end @@ -161,7 +170,7 @@ def min_supported_versions raise "CocoaPods #{name} fixture failed: #{output}" unless status.success? ['Host', 'HostedTests', 'SiblingHostedTests', 'SiblingWithoutMetadata', 'Renamed Host', 'RenamedHostTests', - 'StandaloneTests', 'TestsWithNonKMPHost', 'StaleHostMetadataTests', 'MismatchedLoaderTests', 'SDKHostedTests', 'UnresolvedHostTests', 'PlainHost'].each do |target| + 'CoreOnly', 'StandaloneTests', 'TestsWithNonKMPHost', 'StaleHostMetadataTests', 'MismatchedLoaderTests', 'SDKHostedTests', 'UnresolvedHostTests', 'PlainHost'].each do |target| %w[debug release].each do |configuration| config_path = File.join(fixture, 'Pods', 'Target Support Files', "Pods-#{target}", "Pods-#{target}.#{configuration}.xcconfig") config = Xcodeproj::Config.new(Pathname.new(config_path)).attributes diff --git a/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py b/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py index 43d5b7802d9d..71e740b20676 100644 --- a/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py +++ b/packages/react-native/ReactShared/scripts/test-kotlin-coexistence.py @@ -65,6 +65,15 @@ def main(): sdk = subprocess.check_output(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"], text=True).strip() flags = ["-std=c++20", "-fobjc-arc", "-O2", "-target", f"{architecture}-apple-ios15.1-simulator", "-isysroot", sdk, "-F", str(shared_frameworks), "-F", str(output / "static")] + # React-Core must export the shared classes even when only dependent pods + # call them. Exercise its linker flags without any algorithm references. + empty_owner = output / "libReactNativeRuntimeOnly.dylib" + run(["xcrun", "clang++", *flags, "-dynamiclib", "-ObjC", "-framework", "ReactNativeShared", + "-framework", "Foundation", "-Wl,-dead_strip", "-o", empty_owner], output / "empty-owner-link.log") + empty_symbols = run(["xcrun", "nm", "-gU", empty_owner], output / "empty-owner-symbols.log").stdout + for class_name in ("RNSBase", "RNSGradientStops"): + if f"_OBJC_CLASS_$_{class_name}" not in empty_symbols: + raise RuntimeError(f"An owner without algorithm references did not retain {class_name}.") run(["xcrun", "clang++", *flags, "-c", fixture / "ReactNativeRuntimeOwner.mm", "-o", output / "owner.o"], output / "owner-compile.log") run(["xcrun", "clang++", *flags, "-c", fixture / "AppleKotlinCoexistence.mm", "-o", output / "host.o"], output / "host-compile.log") run(["xcrun", "clang++", *flags, "-dynamiclib", output / "owner.o", "-framework", "ReactNativeShared", "-framework", "Foundation", diff --git a/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt b/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt new file mode 100644 index 000000000000..70ec180d0390 --- /dev/null +++ b/packages/react-native/ReactShared/src/commonMain/kotlin/com/facebook/react/shared/MultipartFraming.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared + +/** A completed region in the caller's current buffer. The first region is the preamble. */ +public class MultipartChunk( + public val start: Long, + public val end: Long, + public val isPart: Boolean, + public val isLast: Boolean, +) + +/** + * Incremental multipart framing, independent of stream and buffer ownership. + * + * The caller searches its native buffer for delimiters starting at [searchStart], then passes their + * indices to [nextChunk]. After a chunk, it may discard bytes before the delimiter and advance + * bufferOffset by the same amount. Offsets let both sliding and retained buffers use the same state + * machine without copying body bytes into Kotlin. + */ +public class MultipartFraming( + private val delimiterLength: Int, + private val closeDelimiterLength: Int, +) { + private var chunkStart: Long = 0 + private var bytesSeen: Long = 0 + private var hasBoundary: Boolean = false + + /** Retain enough overlap to find a delimiter split between two reads. */ + public fun searchStart(bufferOffset: Long): Long = + maxOf(bytesSeen - closeDelimiterLength, chunkStart) - bufferOffset + + /** Start of the current part in the caller's buffer, including its headers. */ + public fun partStart(bufferOffset: Long): Long = chunkStart - bufferOffset + + /** + * Returns a completed region, or null when another read is needed. A negative index means that + * delimiter was not found. Normal delimiters take precedence, matching both adapters. + */ + public fun nextChunk( + bufferLength: Long, + bufferOffset: Long, + delimiterIndex: Long, + closeDelimiterIndex: Long, + ): MultipartChunk? { + val isClosing = delimiterIndex < 0 + val end = if (isClosing) closeDelimiterIndex else delimiterIndex + if (end < 0) { + bytesSeen = bufferOffset + bufferLength + return null + } + + val chunk = MultipartChunk(chunkStart - bufferOffset, end, hasBoundary, isClosing) + if (!isClosing) { + hasBoundary = true + chunkStart = bufferOffset + end + delimiterLength + bytesSeen = chunkStart + } + return chunk + } +} + +/** An untrimmed header. Native adapters retain their whitespace and map-key policies. */ +public class MultipartHeader(public val name: String, public val value: String) + +public object MultipartHeaders { + /** Split CRLF-delimited headers at the first colon, ignoring lines without a separator. */ + public fun parse(text: String): List = + text.split("\r\n").mapNotNull { line -> + val separator = line.indexOf(':') + if (separator < 0) null + else MultipartHeader(line.substring(0, separator), line.substring(separator + 1)) + } +} diff --git a/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt new file mode 100644 index 000000000000..9e67686648de --- /dev/null +++ b/packages/react-native/ReactShared/src/commonTest/kotlin/com/facebook/react/shared/MultipartFramingTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.shared + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MultipartFramingTest { + @Test + fun framingIsIndependentOfReadBoundariesAndBufferRetention() { + val input = + "preamble\r\n--sample\r\nA: b\r\n\r\none\r\n--sample\r\ntwo\r\n--sample--\r\nepilogue" + for (readSize in 1..input.length) { + for (discard in listOf(false, true)) { + assertEquals( + listOf("A: b\r\n\r\none", "two") to true, + parse(input, "sample", readSize, discard), + "readSize=$readSize discard=$discard", + ) + } + } + } + + @Test + fun missingDelimiterDoesNotComplete() { + assertEquals(emptyList() to false, parse("no delimiter", "sample", 1, true)) + } + + @Test + fun missingClosingDelimiterDoesNotComplete() { + assertEquals(emptyList() to false, parse("\r\n--sample\r\nbody", "sample", 1, false)) + } + + @Test + fun incompleteFinalPartKeepsPreviouslyCompletedParts() { + val input = "\r\n--s\r\nfirst\r\n--s\r\nincomplete" + assertEquals(listOf("first") to false, parse(input, "s", 2, true)) + } + + @Test + fun closingDelimiterWithoutPartsOnlyDiscardsPreamble() { + assertEquals(emptyList() to true, parse("preamble\r\n--s--\r\n", "s", 1, false)) + } + + @Test + fun nearMatchesRemainInTheBody() { + val body = "binary\u0000\r\n--samplX\r\n\r\n--sample-\r\n" + val input = "\r\n--sample\r\n$body\r\n--sample--\r\n" + for (readSize in 1..input.length) { + assertEquals(listOf(body) to true, parse(input, "sample", readSize, true)) + } + } + + @Test + fun overlapStartsAtThePartUntilMoreBytesArrive() { + val framing = MultipartFraming(7, 9) + assertEquals(0L, framing.searchStart(0)) + val preamble = framing.nextChunk(7, 0, 0, -1)!! + assertFalse(preamble.isPart) + assertEquals(7L, framing.partStart(0)) + assertEquals(7L, framing.searchStart(0)) + assertNull(framing.nextChunk(30, 0, -1, -1)) + assertEquals(21L, framing.searchStart(0)) + } + + @Test + fun offsetsRemainExactBeyondIntRange() { + val framing = MultipartFraming(7, 9) + val offset = Int.MAX_VALUE.toLong() + 100 + framing.nextChunk(offset + 7, 0, offset, -1) + assertEquals(7L, framing.partStart(offset)) + assertNull(framing.nextChunk(30, offset, -1, -1)) + assertEquals(21L, framing.searchStart(offset)) + val part = framing.nextChunk(40, offset, -1, 31)!! + assertEquals(7L, part.start) + assertEquals(31L, part.end) + assertTrue(part.isPart) + assertTrue(part.isLast) + } + + @Test + fun normalDelimiterRetainsExistingSearchPrecedence() { + val framing = MultipartFraming(7, 9) + assertFalse(framing.nextChunk(40, 0, 20, 5)!!.isLast) + } + + @Test + fun headersRetainNativeWhitespaceCaseAndDuplicatePolicies() { + val headers = + MultipartHeaders.parse( + " Content-Type : text/plain:extra \r\ninvalid\r\nx:1\r\nX:2\r\n:empty\r\n" + ) + assertEquals(listOf(" Content-Type ", "x", "X", ""), headers.map { it.name }) + assertEquals(listOf(" text/plain:extra ", "1", "2", "empty"), headers.map { it.value }) + assertTrue(MultipartHeaders.parse("").isEmpty()) + } + + private fun parse( + input: String, + boundary: String, + readSize: Int, + discard: Boolean, + ): Pair, Boolean> { + val delimiter = "\r\n--$boundary\r\n" + val closeDelimiter = "\r\n--$boundary--\r\n" + val framing = MultipartFraming(delimiter.length, closeDelimiter.length) + var buffer = "" + var offset = 0L + var read = 0 + val parts = mutableListOf() + while (true) { + val start = framing.searchStart(offset).toInt() + val normal = buffer.indexOf(delimiter, start) + val close = if (normal < 0) buffer.indexOf(closeDelimiter, start) else -1 + val chunk = framing.nextChunk(buffer.length.toLong(), offset, normal.toLong(), close.toLong()) + if (chunk == null) { + if (read == input.length) return parts to false + val end = minOf(input.length, read + readSize) + buffer += input.substring(read, end) + read = end + } else { + if (chunk.isPart) parts += buffer.substring(chunk.start.toInt(), chunk.end.toInt()) + if (chunk.isLast) return parts to true + if (discard) { + buffer = buffer.substring(chunk.end.toInt()) + offset += chunk.end + } + } + } + } +} diff --git a/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt b/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt new file mode 100644 index 000000000000..3c2f64003645 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AndroidMultipartBenchmark.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.devsupport + +import java.lang.management.ManagementFactory +import okio.Buffer +import okio.BufferedSource +import okio.ByteString +import okio.ByteString.Companion.toByteString + +// Compiled with the actual adapter and the explicitly selected, renamed Git baseline. +private fun read(native: Boolean, source: BufferedSource, capture: Boolean): Any { + var bytes = 0L + var calls = 0 + var result: ByteString? = null + fun complete(body: BufferedSource, last: Boolean) { + check(last) + calls++ + if (capture) { + result = body.readByteString() + } else { + val scratch = Buffer() + while (body.read(scratch, 8192) != -1L) { + bytes += scratch.size + scratch.clear() + } + } + } + val success = + if (native) { + MultipartStreamReaderBaseline(source, "sample") + .readAllParts( + object : MultipartStreamReaderBaseline.ChunkListener { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) = complete(body, isLastChunk) + + override fun onChunkProgress( + headers: Map, + loaded: Long, + total: Long, + ) = Unit + } + ) + } else { + MultipartStreamReader(source, "sample") + .readAllParts( + object : MultipartStreamReader.ChunkListener { + override fun onChunkComplete( + headers: Map, + body: BufferedSource, + isLastChunk: Boolean, + ) = complete(body, isLastChunk) + + override fun onChunkProgress( + headers: Map, + loaded: Long, + total: Long, + ) = Unit + } + ) + } + check(success && calls == 1) + return result ?: bytes +} + +fun main() { + val bean = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean + check(bean.isThreadAllocatedMemorySupported) + bean.isThreadAllocatedMemoryEnabled = true + val thread = Thread.currentThread().id + for (megabytes in listOf(2, 20)) { + val size = megabytes * 1024 * 1024 + val body = ByteArray(size) { (it % 251).toByte() } + val response = + Buffer() + .apply { + writeUtf8("preamble\r\n--sample\r\nContent-Length: $size\r\n\r\n") + write(body) + writeUtf8("\r\n--sample--\r\nepilogue") + } + .readByteArray() + val nativeBody = read(true, Buffer().write(response), true) + val sharedBody = read(false, Buffer().write(response), true) + check(nativeBody == sharedBody && sharedBody == body.toByteString()) + + fun measure(native: Boolean): Pair { + val source = Buffer().write(response) + val allocated = bean.getThreadAllocatedBytes(thread) + val start = System.nanoTime() + val result = read(native, source, false) + val elapsed = (System.nanoTime() - start) / 1_000_000.0 + val bytes = bean.getThreadAllocatedBytes(thread) - allocated + check(result == size.toLong()) + return elapsed to bytes + } + repeat(30) { + measure(true) + measure(false) + } + val native = mutableListOf>() + val shared = mutableListOf>() + repeat(41) { i -> + if (i % 2 == 0) { + native += measure(true) + shared += measure(false) + } else { + shared += measure(false) + native += measure(true) + } + } + val baselineMs = native.map { it.first }.sorted()[20] + val sharedMs = shared.map { it.first }.sorted()[20] + println( + """{"bytes":$size,"iterations":41,"nativeMedianMs":$baselineMs,"kmpMedianMs":$sharedMs,"ratio":${sharedMs / baselineMs},"nativeMedianAllocatedBytes":${native.map { it.second }.sorted()[20]},"kmpMedianAllocatedBytes":${shared.map { it.second }.sorted()[20]},"nativeSamplesMs":${native.map { it.first }},"kmpSamplesMs":${shared.map { it.first }}}""" + ) + } +} diff --git a/packages/react-native/ReactShared/tests/AppleMultipartParity.m b/packages/react-native/ReactShared/tests/AppleMultipartParity.m new file mode 100644 index 000000000000..df627767a9d1 --- /dev/null +++ b/packages/react-native/ReactShared/tests/AppleMultipartParity.m @@ -0,0 +1,177 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import +#import +#import + +@interface RCTMultipartStreamReaderBaseline : NSObject +- (instancetype)initWithInputStream:(NSInputStream *)stream boundary:(NSString *)boundary; +- (BOOL)readAllPartsWithCompletionCallback:(RCTMultipartCallback)callback + progressCallback:(RCTMultipartProgressCallback)progressCallback; +@end + +@interface FragmentedMultipartStream : NSInputStream +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize; +@end + +@implementation FragmentedMultipartStream { + NSData *_data; + NSUInteger _offset; + NSUInteger _readSize; +} +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize +{ + if (self = [super init]) { + _data = data; + _readSize = readSize; + } + return self; +} +- (void)open +{ +} +- (NSError *)streamError +{ + return nil; +} +- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)length +{ + NSUInteger count = MIN(MIN(length, _readSize), _data.length - _offset); + [_data getBytes:buffer range:NSMakeRange(_offset, count)]; + _offset += count; + return count; +} +@end + +static NSDictionary *Read(Class readerClass, NSData *data, NSUInteger readSize, BOOL retainBodies) +{ + NSInputStream *stream = [[FragmentedMultipartStream alloc] initWithData:data readSize:readSize]; + RCTMultipartStreamReader *reader = [[readerClass alloc] initWithInputStream:stream boundary:@"sample"]; + NSMutableArray *parts = [NSMutableArray new]; + NSMutableArray *completedProgress = [NSMutableArray new]; + __block NSArray *progress; + BOOL success = [reader + readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *body, BOOL done) { + [parts addObject:@[ headers ?: @{}, retainBodies ? (id)body : @(body.length), @(done) ]]; + [completedProgress addObject:progress ?: @[]]; + progress = nil; + } + progressCallback:^(NSDictionary *headers, NSNumber *length, NSNumber *loaded) { + progress = @[ headers, length, loaded ]; + }]; + return @{@"success" : @(success), @"parts" : parts, @"finalProgress" : completedProgress}; +} + +static void Require(BOOL condition, NSString *message) +{ + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message.UTF8String); + exit(1); + } +} + +static NSData *Response(NSUInteger size) +{ + NSMutableData *data = [[NSString stringWithFormat:@"preamble\r\n--sample\r\nContent-Length: %lu\r\n\r\n", + (unsigned long)size] dataUsingEncoding:NSUTF8StringEncoding] + .mutableCopy; + NSMutableData *body = [NSMutableData dataWithLength:size]; + // Deterministic binary content, without a valid delimiter or header separator. + uint8_t *bytes = body.mutableBytes; + for (NSUInteger i = 0; i < size; i++) + bytes[i] = (uint8_t)(i % 251); + [data appendData:body]; + [data appendData:[@"\r\n--sample--\r\nepilogue" dataUsingEncoding:NSUTF8StringEncoding]]; + return data; +} + +static double Measure(Class readerClass, NSData *data, NSUInteger size) +{ + CFTimeInterval start = CACurrentMediaTime(); + @autoreleasepool { + NSDictionary *result = Read(readerClass, data, 4096, NO); + Require([result[@"success"] boolValue], @"benchmark completion"); + Require( + [result[@"parts"] count] == 1 && [result[@"parts"][0][1] unsignedIntegerValue] == size, + @"benchmark body length"); + } + return (CACurrentMediaTime() - start) * 1000; +} + +int main(int argc, const char *argv[]) +{ + @autoreleasepool { + Class native = RCTMultipartStreamReaderBaseline.class; + Class shared = RCTMultipartStreamReader.class; + if (argc == 2 && strcmp(argv[1], "--benchmark") == 0) { + for (NSNumber *megabytes in @[ @2, @20 ]) { + NSUInteger size = megabytes.unsignedIntegerValue * 1024 * 1024; + NSData *data = Response(size); + Require([Read(native, data, 4096, YES) isEqual:Read(shared, data, 4096, YES)], @"large body exact parity"); + for (NSUInteger i = 0; i < 5; i++) { + Measure(native, data, size); + Measure(shared, data, size); + } + NSMutableArray *nativeSamples = [NSMutableArray new]; + NSMutableArray *sharedSamples = [NSMutableArray new]; + for (NSUInteger i = 0; i < 21; i++) { + if (i % 2 == 0) { + [nativeSamples addObject:@(Measure(native, data, size))]; + [sharedSamples addObject:@(Measure(shared, data, size))]; + } else { + [sharedSamples addObject:@(Measure(shared, data, size))]; + [nativeSamples addObject:@(Measure(native, data, size))]; + } + } + double baseline = [[nativeSamples sortedArrayUsingSelector:@selector(compare:)][10] doubleValue]; + double kmp = [[sharedSamples sortedArrayUsingSelector:@selector(compare:)][10] doubleValue]; + NSDictionary *result = @{ + @"bytes" : @(size), + @"iterations" : @21, + @"nativeMedianMs" : @(baseline), + @"kmpMedianMs" : @(kmp), + @"ratio" : @(kmp / baseline), + @"nativeSamplesMs" : nativeSamples, + @"kmpSamplesMs" : sharedSamples + }; + puts([[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:result options:0 error:nil] + encoding:NSUTF8StringEncoding] + .UTF8String); + } + return 0; + } + + NSArray *inputs = @[ + @"Yolo", + @"preamble\r\n--sample--\r\n", + @"\r\n--sample\r\none\r\n--sample\r\ntwo\r\n--sample--\r\nepilogue", + @"\r\n--sample\r\nX: a:b\r\nx: c\r\n invalid \r\n\r\nbody\r\n--sample--\r\n", + @"\r\n--sample\r\nfirst\r\n--sample\r\nincomplete", + @"\r\n--sample\r\nbinary\0\r\n--samplX\r\n--sample-\r\n--sample--\r\n" + ]; + NSUInteger cases = 0; + for (NSString *input in inputs) { + NSData *data = [input dataUsingEncoding:NSUTF8StringEncoding]; + for (NSUInteger readSize = 1; readSize <= data.length; readSize++) { + Require( + [Read(native, data, readSize, YES) isEqual:Read(shared, data, readSize, YES)], + [NSString stringWithFormat:@"native/KMP parity case %lu read %lu", + (unsigned long)cases, + (unsigned long)readSize]); + cases++; + } + } + for (NSNumber *size in @[ @4095, @4096, @4097, @65536 ]) { + NSData *data = Response(size.unsignedIntegerValue); + Require([Read(native, data, 4096, YES) isEqual:Read(shared, data, 4096, YES)], @"binary body/progress parity"); + cases++; + } + printf("PASS: %lu real Apple multipart adapter parity cases\n", (unsigned long)cases); + } + return 0; +} diff --git a/packages/react-native/scripts/cocoapods/kmp.rb b/packages/react-native/scripts/cocoapods/kmp.rb index 4957a6d522d7..1d4ba752a8aa 100644 --- a/packages/react-native/scripts/cocoapods/kmp.rb +++ b/packages/react-native/scripts/cocoapods/kmp.rb @@ -12,8 +12,9 @@ def self.configure_aggregate_xcconfig(installer) linked_pods = aggregate_target.build_settings(config_name).pod_targets_to_link next unless linked_pods.any? { |pod| pod.pod_name == 'React-KMP' } - # Dynamic RCTFabric already contains the static Kotlin runtime. - next if linked_pods.any? { |pod| pod.pod_name == 'React-RCTFabric' && pod.build_as_dynamic? } + # Dynamic React-Core already contains the static Kotlin runtime. Other + # consumers use their existing React-Core dependency to share that owner. + next if linked_pods.any? { |pod| pod.pod_name == 'React-Core' && pod.build_as_dynamic? } %w[iphoneos iphonesimulator].each do |sdk| # Full-pod sibling tests also reuse their host's runtime. Resolve each # SDK separately because TEST_HOST and product settings can be conditional. diff --git a/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m b/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m index 4e711d03f56e..84688e4fdaf5 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTMultipartStreamReaderTests.m @@ -9,6 +9,44 @@ #import +@interface RCTMultipartFragmentedInputStream : NSInputStream +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize; +@end + +@implementation RCTMultipartFragmentedInputStream { + NSData *_data; + NSUInteger _offset; + NSUInteger _readSize; +} + +- (instancetype)initWithData:(NSData *)data readSize:(NSUInteger)readSize +{ + if (self = [super init]) { + _data = data; + _readSize = readSize; + } + return self; +} + +- (void)open +{ +} + +- (NSError *)streamError +{ + return nil; +} + +- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)length +{ + NSUInteger count = MIN(MIN(length, _readSize), _data.length - _offset); + [_data getBytes:buffer range:NSMakeRange(_offset, count)]; + _offset += count; + return count; +} + +@end + @interface RCTMultipartStreamReaderTests : XCTestCase @end @@ -115,4 +153,76 @@ - (void)testNoCloseDelimiter XCTAssertEqual(count, 1); } +- (void)testDelimitersAcrossEveryReadBoundary +{ + NSString *body = @"binary\0\r\n--samplX\r\n--sample-\r\n"; + NSString *response = + [NSString stringWithFormat:@"preamble\r\n--sample\r\n%@\r\n--sample\r\nsecond\r\n--sample--\r\nepilogue", body]; + NSData *data = [response dataUsingEncoding:NSUTF8StringEncoding]; + for (NSUInteger readSize = 1; readSize <= data.length; readSize++) { + NSInputStream *stream = [[RCTMultipartFragmentedInputStream alloc] initWithData:data readSize:readSize]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:stream boundary:@"sample"]; + NSMutableArray *parts = [NSMutableArray new]; + NSMutableArray *last = [NSMutableArray new]; + BOOL success = [reader + readAllPartsWithCompletionCallback:^(__unused NSDictionary *headers, NSData *content, BOOL done) { + [parts addObject:content]; + [last addObject:@(done)]; + } + progressCallback:nil]; + XCTAssertTrue(success, @"read size %lu", (unsigned long)readSize); + XCTAssertEqualObjects( + parts, + (@[ [body dataUsingEncoding:NSUTF8StringEncoding], [@"second" dataUsingEncoding:NSUTF8StringEncoding] ])); + XCTAssertEqualObjects(last, (@[ @NO, @YES ])); + } +} + +- (void)testHeaderWhitespaceDuplicatesAndColonValues +{ + NSString *response = + @"\r\n--sample\r\n X-Name : first\r\nx-name: second:extra \r\nx-name: last:extra \r\ninvalid\r\n\r\nbody\r\n--sample--\r\n"; + NSInputStream *stream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:stream boundary:@"sample"]; + __block NSUInteger calls = 0; + BOOL success = [reader + readAllPartsWithCompletionCallback:^(NSDictionary *headers, NSData *content, BOOL done) { + calls++; + // Apple keeps header names verbatim and only trims values. + XCTAssertEqualObjects(headers, (@{@" X-Name " : @"first", @"x-name" : @"last:extra"})); + XCTAssertEqualObjects(content, [@"body" dataUsingEncoding:NSUTF8StringEncoding]); + XCTAssertTrue(done); + } + progressCallback:nil]; + XCTAssertTrue(success); + XCTAssertEqual(calls, 1); +} + +- (void)testFinalProgressForFragmentedBody +{ + NSString *body = [@"" stringByPaddingToLength:64 * 1024 withString:@"x" startingAtIndex:0]; + NSString *response = [NSString stringWithFormat:@"\r\n--sample\r\nContent-Length: %lu\r\n\r\n%@\r\n--sample--\r\n", + (unsigned long)body.length, + body]; + NSInputStream *stream = [NSInputStream inputStreamWithData:[response dataUsingEncoding:NSUTF8StringEncoding]]; + RCTMultipartStreamReader *reader = [[RCTMultipartStreamReader alloc] initWithInputStream:stream boundary:@"sample"]; + __block NSUInteger calls = 0; + __block NSNumber *lastLength; + __block NSNumber *lastLoaded; + BOOL success = [reader + readAllPartsWithCompletionCallback:^( + __unused NSDictionary *headers, __unused NSData *content, __unused BOOL done) { + calls++; + } + progressCallback:^(__unused NSDictionary *headers, NSNumber *length, NSNumber *loaded) { + lastLength = length; + lastLoaded = loaded; + }]; + XCTAssertTrue(success); + XCTAssertEqual(calls, 1); + XCTAssertEqualObjects(lastLength, @(body.length)); + // Preserve Apple's existing progress accounting, including the header/body separator. + XCTAssertEqualObjects(lastLoaded, @(body.length + 4)); +} + @end