Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion native-modules/react-native-sni-connect/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,9 @@ export type SniConnectUploadFileRequest = SniConnectRequestBase & {
I/O.
- The caller supplies `content-type` in `headers`; `Content-Length` stays
module-owned (Section 4.4).
- Upload progress events are out of scope. `timeout` covers the whole upload.
- Native React Native upload progress is available through
`getUploadProgress(requestId)` as described in the
[native upload contract](docs/SPEC.md). `timeout` covers the whole upload.
- Status: **Implemented** on iOS and Android React Native. Node/Desktop and
shared JS adapters do not expose it; adapters MUST feature-detect it and
treat a missing native method as "not supported" (Section 3.3).
Expand Down Expand Up @@ -220,6 +222,8 @@ export type SniConnectResponse = {
- `isUploadFileSupported(): boolean` (React Native JS capability probe; see
[native upload contract](docs/SPEC.md) for unsupported Promise rejection and
consumption-time file bounds)
- `getUploadProgress(requestId): Promise<{ bytesSent: number; totalBytes: number } | null>`
(React Native only; requires the matching JS and native release)
- `cancelRequest(requestId): Promise<{ success: boolean }>`
- `cancelAllRequests(): Promise<{ success: boolean }>`
- `clearDNSCache(): Promise<{ success: boolean }>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
private class ManagedRequest(
val call: Call,
val settled: AtomicBoolean,
val uploadProgress: UploadProgress?,
) {
lateinit var admissionTicket: SniConnectRequestAdmission.Ticket
private val explicitlyCancelled = AtomicBoolean(false)
Expand All @@ -183,6 +184,20 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
fun wasExplicitlyCancelled(): Boolean = explicitlyCancelled.get()
}

private class UploadProgress {
private var bytesSent = 0L
private var totalBytes = 0L

@Synchronized
fun record(sent: Long, total: Long) {
totalBytes = total
bytesSent = maxOf(bytesSent, sent.coerceIn(0, total))
}

@Synchronized
fun snapshot(): Pair<Long, Long> = bytesSent to totalBytes
}

// Cancellation ownership is per module, so cancelAllRequests only affects its RN runtime.
private val activeCalls = ConcurrentHashMap<String, ManagedRequest>()
private val allActiveCalls = Collections.newSetFromMap(
Expand Down Expand Up @@ -242,6 +257,20 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
}
}

@ReactMethod
override fun getUploadProgress(requestId: String, promise: Promise) {
val progress = synchronized(activeCallsLock) {
activeCalls[requestId]?.takeUnless { it.settled.get() }?.uploadProgress
}
val snapshot = progress?.snapshot()
promise.resolve(snapshot?.let { (sent, total) ->
Arguments.createMap().apply {
putDouble("bytesSent", sent.toDouble())
putDouble("totalBytes", total.toDouble())
}
})
}

@ReactMethod
override fun cancelRequest(requestId: String, promise: Promise) {
val request = synchronized(activeCallsLock) {
Expand Down Expand Up @@ -337,10 +366,15 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
// Shared by every OkHttp attempt of this logical request. The client's
// EventListener marks it once any attempt starts sending request headers.
val requestTransmission = SniRequestTransmissionState()
val uploadProgress = if (config.bodyFile != null && config.requestId != null) {
UploadProgress()
} else {
null
}
var registeredRequest: ManagedRequest? = null
try {
val client = getOrCreateClient(config)
val request = buildRequest(config, requestTransmission)
val request = buildRequest(config, requestTransmission, uploadProgress)
val call = client.newCall(request)
val settled = AtomicBoolean(false)
lateinit var managedRequest: ManagedRequest
Expand Down Expand Up @@ -493,7 +527,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
}
},
)
managedRequest = ManagedRequest(call, settled).apply {
managedRequest = ManagedRequest(call, settled, uploadProgress).apply {
this.admissionTicket = admissionTicket
}
registerRequest(config.requestId, managedRequest)
Expand Down Expand Up @@ -632,6 +666,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
private fun buildRequest(
config: RequestConfig,
requestTransmission: SniRequestTransmissionState,
uploadProgress: UploadProgress?,
): Request {
val url = "https://${config.hostname}${config.path}"
val builder = Request.Builder().url(url)
Expand All @@ -651,7 +686,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
?.value
?.toMediaTypeOrNull()
// Enforce the body bound when OkHttp consumes the file, after admission.
val requestBody = config.bodyFile?.let { SniUploadFileRequestBody(it, mediaType) }
val requestBody = config.bodyFile?.let {
SniUploadFileRequestBody(it, mediaType) { sent, total ->
uploadProgress?.record(sent, total)
}.also { body -> uploadProgress?.record(0, body.contentLength()) }
}
?: config.body?.toRequestBody(mediaType)

when (method) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ internal object SniRequestTransmissionEventListenerFactory : EventListener.Facto
internal class SniUploadFileRequestBody(
private val file: File,
private val mediaType: MediaType?,
private val onProgress: ((Long, Long) -> Unit)? = null,
) : RequestBody() {
private val length = file.length().also {
if (it <= 0 || it > SniConnectValidation.MAX_UPLOAD_FILE_BYTES) {
Expand All @@ -125,6 +126,7 @@ internal class SniUploadFileRequestBody(
override fun contentLength(): Long = length

override fun writeTo(sink: BufferedSink) {
onProgress?.invoke(0, length)
file.source().use { source ->
val buffer = Buffer()
var totalBytes = 0L
Expand All @@ -137,6 +139,7 @@ internal class SniUploadFileRequestBody(
}
sink.write(buffer, read)
totalBytes += read
onProgress?.invoke(totalBytes, length)
}
if (totalBytes != length) {
throw IOException("Upload file was truncated during transmission")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ class SniConnectValidationTest {
assertTrue(expected.contentEquals(sink.readByteArray()))
}

@Test
fun uploadBodyReportsOnlyAcceptedPayloadBytesPerWrite() {
val file = tempFolder.newFile("progress.zip").apply { writeBytes(ByteArray(16 * 1024) { 1 }) }
val progress = mutableListOf<Pair<Long, Long>>()
val body = SniUploadFileRequestBody(file, null) { sent, total ->
progress.add(sent to total)
}
val sink = Buffer()
body.writeTo(sink)
assertEquals(0L to file.length(), progress.first())
assertEquals(file.length() to file.length(), progress.last())
assertTrue(progress.zipWithNext().all { (before, after) -> after.first >= before.first })
assertEquals(file.length(), sink.size)
}

@Test
fun uploadBodyRejectsGrowthAfterValidation() {
val root = tempFolder.newFolder("sandbox")
Expand Down
40 changes: 40 additions & 0 deletions native-modules/react-native-sni-connect/docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@ upload boundary and its focused review corrections.
- `uploadFile(config)` always returns a Promise. When the native method is
absent, it rejects with an Error whose `code` is `SNI_UNSUPPORTED`.
- Supported native methods receive the caller's config unchanged.
- `getUploadProgress(requestId)` returns the latest active upload snapshot or
`null` for an unknown, inactive, or non-upload request ID. JS calls the native
method directly; the JS and native changes must ship together. This does not
change `uploadFile(config)` or its response/error behavior.

## Upload progress

Status: **Implemented in source**; native device/network acceptance remains open.

- Progress is opt-in through the existing optional `requestId`; uploads without
one continue unchanged and have no queryable snapshot. Each native module
instance owns its own snapshots. The ID is validated by existing request
validation; a progress query itself performs no network I/O.
- `bytesSent` and `totalBytes` are payload bytes, not headers or a server
acknowledgement. Once the body is prepared, `totalBytes` is its declared
length, 1 through 20 MiB; before preparation it is zero. `bytesSent` is
between zero and `totalBytes`. iOS counts bytes read by EMASCurl into
libcurl; Android counts bytes accepted by OkHttp's sink. Built-in transport
recovery may replay a body. Android resets its write count for each OkHttp
`writeTo` and retains the largest prefix. EMASCurl's public callback counts
reads over one URLProtocol instance without an exposed retry boundary, so
iOS clamps that cumulative value at `totalBytes`; it may reach 100% across
replayed reads before a single complete attempt. Counts never accumulate
across separate `uploadFile` calls.
- A snapshot becomes available after native registration and remains available
while the upload waits for admission, sends, or awaits its response. Before
the file body is constructed, `totalBytes` may be zero. It is removed on
settlement, cancellation, or module invalidation. Late transport callbacks
cannot restore a removed snapshot. Duplicate active IDs replace the older
operation; completion of the older one cannot erase the replacement.
- Snapshots are retained only for registered operations. Admission limits
steady-state work to 64 active and 256 pending requests process-wide;
concurrent pre-admission registrations can transiently exceed those counts
and are released when admission rejects or the operation settles. Polling is
caller owned; the native module emits no progress events or bridge callbacks.

## File ownership, lifecycle, and bounds

Expand Down Expand Up @@ -233,6 +268,11 @@ retain the generic logger's sensitive-data filtering.

## Conformance and remaining evidence

- TypeScript typecheck and React Native Codegen parse cover the nullable
progress result; JS API tests cover forwarding.
- Android RequestBody test verifies progress from sink-accepted payload bytes.
iOS uses EMASCurl 1.5.5's per-request callback, which counts bytes read into
libcurl. Native build and device/network behavior remain unverified here.
- JS API tests cover capability detection, missing-native Promise rejection,
supported forwarding, and synchronous native errors becoming rejections.
- Swift validation tests exercise bounded reads after validated files grow or
Expand Down
15 changes: 15 additions & 0 deletions native-modules/react-native-sni-connect/ios/SniConnect.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ - (void)request:(NSDictionary *)config
- (void)uploadFile:(NSDictionary *)config
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject;
- (void)getUploadProgress:(NSString *)requestId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject;
- (void)cancelRequest:(NSString *)requestId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject;
Expand Down Expand Up @@ -103,6 +106,12 @@ - (void)uploadFile:(JS::NativeSniConnect::NativeSniConnectUploadFileRequest &)co
[_implementation uploadFile:configDict resolve:resolve reject:reject];
}

- (void)getUploadProgress:(NSString *)requestId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
[_implementation getUploadProgress:requestId resolve:resolve reject:reject];
}

- (void)cancelRequest:(NSString *)requestId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
Expand Down Expand Up @@ -154,6 +163,12 @@ - (void)isProxyActiveForUrl:(NSString *)url
[_implementation uploadFile:config resolve:resolve reject:reject];
}

RCT_EXPORT_METHOD(getUploadProgress:(NSString *)requestId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
[_implementation getUploadProgress:requestId resolve:resolve reject:reject];
}

RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
Expand Down
26 changes: 24 additions & 2 deletions native-modules/react-native-sni-connect/ios/SniConnect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,25 @@ final class SniConnectImpl: NSObject {
}
}

@objc
public func getUploadProgress(
_ requestId: String,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
resolve(client.getUploadProgress(requestId: requestId))
}

private func handleRequest(
config: SniConnectClient.RequestConfig,
config originalConfig: SniConnectClient.RequestConfig,
resolve: @escaping RCTPromiseResolveBlock,
reject: @escaping RCTPromiseRejectBlock
) {
var configured = originalConfig
if configured.bodyFileURL != nil && configured.requestId != nil {
configured.uploadProgress = SniConnectUploadProgress()
}
let config = configured
do {
try Self.validate(config)
} catch {
Expand All @@ -92,7 +106,11 @@ final class SniConnectImpl: NSObject {
return try await client.performRequest(config: config)
}

let registrationToken = client.registerTask(task, for: config.requestId)
let registrationToken = client.registerTask(
task,
for: config.requestId,
uploadProgress: config.uploadProgress
)

// Handle the task result asynchronously
Task {
Expand All @@ -102,6 +120,7 @@ final class SniConnectImpl: NSObject {

do {
let result = try await task.value
client.finishUploadProgress(token: registrationToken)
resolve([
"data": result.data,
"status": result.status,
Expand All @@ -110,10 +129,13 @@ final class SniConnectImpl: NSObject {
"multiValueHeaders": result.multiValueHeaders,
])
} catch let error as SniConnectClient.SniConnectError {
client.finishUploadProgress(token: registrationToken)
reject(error.code, error.message, error)
} catch is CancellationError {
client.finishUploadProgress(token: registrationToken)
reject("SNI_CANCELLED", "Request cancelled", nil)
} catch {
client.finishUploadProgress(token: registrationToken)
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
("result", "error"),
("code", "SNI_UNKNOWN_ERROR"),
Expand Down
Loading
Loading