Skip to content

Commit 44faae0

Browse files
authored
Merge pull request #5 from padelai/fix/stream-chunks-to-avoid-oom
Fix: stream chunk uploads from disk to avoid OOM on large files
2 parents 5bcd38e + 93f1c13 commit 44faae0

3 files changed

Lines changed: 83 additions & 36 deletions

File tree

android/src/main/java/com/margelo/nitro/nitroclouduploader/NitroCloudUploader.kt

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import androidx.core.app.NotificationCompat
2020
import com.margelo.nitro.core.Promise
2121
import okhttp3.*
2222
import okhttp3.MediaType.Companion.toMediaType
23-
import okhttp3.RequestBody.Companion.toRequestBody
2423
import java.io.File
2524
import java.io.RandomAccessFile
2625
import java.util.concurrent.TimeUnit
@@ -601,15 +600,11 @@ class NitroCloudUploader(
601600
try {
602601
println("📤 Uploading part ${part.partNumber}/${state.totalChunks} (attempt ${retries + 1})")
603602

604-
// ✅ Read chunk
605-
val chunkData = readChunk(filePath, part.offset, part.size)
606-
607-
// ✅ Upload chunk
603+
// Stream the chunk from disk to avoid allocating the full part in memory.
608604
val request = Request.Builder()
609605
.url(part.url)
610-
.put(chunkData.toRequestBody("application/octet-stream".toMediaType()))
606+
.put(streamingFileChunkBody(filePath, part.offset, part.size))
611607
.addHeader("Content-Type", "application/octet-stream")
612-
.addHeader("Content-Length", part.size.toString())
613608
.build()
614609

615610
val response = httpClient.newCall(request).execute()
@@ -722,21 +717,40 @@ class NitroCloudUploader(
722717
false
723718
}
724719

725-
private fun readChunk(filePath: String, offset: Long, size: Long): ByteArray {
726-
return RandomAccessFile(File(filePath), "r").use { raf ->
727-
if (offset + size > raf.length()) {
728-
throw IllegalArgumentException("Invalid chunk bounds: offset=$offset, size=$size, file=${raf.length()}")
729-
}
730-
731-
raf.seek(offset)
732-
val buffer = ByteArray(size.toInt())
733-
val bytesRead = raf.read(buffer)
734-
735-
if (bytesRead != size.toInt()) {
736-
throw Exception("Read $bytesRead bytes, expected $size")
720+
/**
721+
* Streams a byte range from disk into an OkHttp RequestBody.
722+
*
723+
* Each write uses a fixed-size buffer instead of allocating the full chunk.
724+
* The file is reopened for each write so the body can be replayed if OkHttp retries.
725+
*/
726+
private fun streamingFileChunkBody(filePath: String, offset: Long, size: Long): RequestBody {
727+
val mediaType = "application/octet-stream".toMediaType()
728+
return object : RequestBody() {
729+
override fun contentType() = mediaType
730+
override fun contentLength() = size
731+
override fun writeTo(sink: okio.BufferedSink) {
732+
RandomAccessFile(File(filePath), "r").use { raf ->
733+
if (offset + size > raf.length()) {
734+
throw IllegalArgumentException(
735+
"Invalid chunk bounds: offset=$offset, size=$size, file=${raf.length()}",
736+
)
737+
}
738+
raf.seek(offset)
739+
val buffer = ByteArray(256 * 1024) // 256 KB
740+
var remaining = size
741+
while (remaining > 0) {
742+
val toRead = minOf(buffer.size.toLong(), remaining).toInt()
743+
val read = raf.read(buffer, 0, toRead)
744+
if (read <= 0) {
745+
throw java.io.IOException(
746+
"Unexpected EOF at offset ${offset + (size - remaining)}",
747+
)
748+
}
749+
sink.write(buffer, 0, read)
750+
remaining -= read
751+
}
752+
}
737753
}
738-
739-
buffer
740754
}
741755
}
742756

ios/NitroCloudUploader.swift

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -578,8 +578,7 @@ class UploadSession: NSObject {
578578
let tempDir = FileManager.default.temporaryDirectory
579579
let tempFileURL = tempDir.appendingPathComponent("chunk_\(uploadId)_\(index).tmp")
580580

581-
let data = try readChunkData(offset: offset, size: size)
582-
try data.write(to: tempFileURL)
581+
try streamChunkToFile(offset: offset, size: size, destination: tempFileURL)
583582

584583
guard let url = URL(string: uploadUrls[index]) else {
585584
throw NSError(domain: "CloudUploader", code: -1,
@@ -599,12 +598,47 @@ class UploadSession: NSObject {
599598
}
600599
}
601600

602-
private func readChunkData(offset: Int64, size: Int64) throws -> Data {
603-
let handle = try FileHandle(forReadingFrom: fileURL)
604-
defer { try? handle.close() }
605-
606-
try handle.seek(toOffset: UInt64(offset))
607-
return handle.readData(ofLength: Int(size))
601+
/// Copies a byte range from the source file into a temporary chunk file.
602+
///
603+
/// Uses a fixed-size buffer instead of loading the full chunk into memory.
604+
/// Throws on short reads to avoid uploading incomplete chunk data.
605+
private func streamChunkToFile(offset: Int64, size: Int64, destination: URL) throws {
606+
let bufferSize = 256 * 1024 // 256 KB
607+
let fm = FileManager.default
608+
609+
let readHandle = try FileHandle(forReadingFrom: fileURL)
610+
defer { try? readHandle.close() }
611+
try readHandle.seek(toOffset: UInt64(offset))
612+
613+
// Recreate the temp file so each chunk write starts from a clean file.
614+
if fm.fileExists(atPath: destination.path) {
615+
try fm.removeItem(at: destination)
616+
}
617+
guard fm.createFile(atPath: destination.path, contents: nil, attributes: nil) else {
618+
throw NSError(domain: "CloudUploader", code: -1,
619+
userInfo: [NSLocalizedDescriptionKey: "Failed to create temp file at \(destination.path)"])
620+
}
621+
622+
do {
623+
let writeHandle = try FileHandle(forWritingTo: destination)
624+
defer { try? writeHandle.close() }
625+
626+
var remaining = size
627+
while remaining > 0 {
628+
let toRead = Int(min(Int64(bufferSize), remaining))
629+
let chunk = readHandle.readData(ofLength: toRead)
630+
if chunk.isEmpty {
631+
throw NSError(domain: "CloudUploader", code: -1,
632+
userInfo: [NSLocalizedDescriptionKey: "Unexpected EOF reading chunk at offset \(offset): wanted \(size) bytes, short by \(remaining)"])
633+
}
634+
try writeHandle.write(contentsOf: chunk)
635+
remaining -= Int64(chunk.count)
636+
}
637+
} catch {
638+
// Remove any partial file so it cannot be uploaded later.
639+
try? fm.removeItem(at: destination)
640+
throw error
641+
}
608642
}
609643

610644
private func complete() {

src/NitroCloudUploader.nitro.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import type { HybridObject } from 'react-native-nitro-modules';
44
* Upload progress event for UI updates
55
*/
66
export interface UploadProgressEvent {
7-
type: string;
7+
type: string;
88
uploadId: string;
9-
progress?: number;
9+
progress?: number;
1010
bytesUploaded?: number;
1111
totalBytes?: number;
1212
chunkIndex?: number;
@@ -42,26 +42,25 @@ export interface UploadState {
4242
* - Background uploads with optional notifications
4343
* - Event system for UI feedback
4444
* - Parallel chunk uploads
45-
*
45+
*
4646
* Note: Chunk size is calculated automatically: ceil(fileSize / numberOfURLs)
4747
*/
4848
export interface NitroCloudUploader
4949
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
50-
5150
/**
5251
* Start uploading a file in chunks
5352
* - Supports pause/resume/cancel
5453
* - Automatically handles network drops
5554
* - Works in background with optional notifications
5655
* - Uploads multiple chunks in parallel (default: 3)
57-
*
56+
*
5857
* @param uploadId - Unique identifier
5958
* @param filePath - Local file path (decoded, no file://)
6059
* @param uploadUrls - Presigned URLs for each chunk
6160
* @param maxParallel - Number of chunks to upload simultaneously (default: 3)
6261
* @param showNotification - Show progress notification (default: true)
6362
* @returns Promise that resolves when upload completes
64-
*
63+
*
6564
* Note: Chunk size is calculated automatically: ceil(fileSize / uploadUrls.length)
6665
*/
6766
startUpload(
@@ -112,4 +111,4 @@ export interface NitroCloudUploader
112111
* Remove event listener
113112
*/
114113
removeListener(eventType: string): void;
115-
}
114+
}

0 commit comments

Comments
 (0)