From 39afc0ec8f2d88aa4e6ee673733b2ad1c83a03bf Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 4 Sep 2026 14:48:28 +0800 Subject: [PATCH 1/2] fix(download): reserve the next probe interval on unknown-length streams Follow-up to the #635 review: the streaming free-space probe reserved only the current chunk, so up to 8 MiB could be written between two probes with nothing standing behind the margin. All three downloaders now probe before the first body byte and then every kUnknownLengthFreeSpaceProbeBytes, each probe reserving that many bytes ahead (Android probes before sink.emit() writes the chunk it just read). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0149oHt3QpNA3XNocSBVFNCh --- .../modules/update/DownloadTask.java | 23 +++++++++-------- cpp/patch_core/archive_limits.h | 4 ++- harmony/pushy/src/main/ets/DownloadTask.ts | 21 ++++++++++------ ios/RCTPushy/RCTPushyDownloader.mm | 25 +++++++++++-------- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index c63458e2..5ee393d4 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -352,7 +352,13 @@ private boolean transferArchive( long received = 0; int currentPercentage = 0; long lastPostedBytes = baseOffset; - long lastFreeSpaceProbeBytes = 0; + // Unknown length: the response-time check could only reserve the + // margin, so the disk is probed before the first write and then + // every PROBE bytes, each probe reserving the next PROBE bytes — + // the writes between two probes can never eat into the margin + // (the archive cap alone is far more than the margin). A throw + // keeps the partial for a later attempt. + long nextFreeSpaceProbeAt = 0; try ( BufferedSource source = body.source(); @@ -361,6 +367,12 @@ private boolean transferArchive( ) { while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) { received += bytesRead; + if (totalAll <= 0 && received - bytesRead >= nextFreeSpaceProbeAt) { + nextFreeSpaceProbeAt = received - bytesRead + + ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; + ArchiveLimits.ensureFreeSpace( + writePath, ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES); + } sink.emit(); long overall = baseOffset + received; @@ -369,15 +381,6 @@ private boolean transferArchive( throw new IOException( "archive too large: exceeded " + ArchiveLimits.MAX_ARCHIVE_BYTES); } - if (totalAll <= 0 && received - lastFreeSpaceProbeBytes - >= ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES) { - // Unknown length: the response-time check could only - // reserve the margin, so re-probe as bytes stream in - // (the archive cap alone is far more than the margin). - // A throw keeps the partial for a later attempt. - lastFreeSpaceProbeBytes = received; - ArchiveLimits.ensureFreeSpace(writePath, DOWNLOAD_CHUNK_SIZE); - } if (totalAll > 0) { int percentage = (int) (overall * 100.0 / totalAll + 0.5); if (percentage > currentPercentage) { diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 98b9e643..2f8135f0 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -33,7 +33,9 @@ constexpr long long kMaxManifestBytes = 16LL * 1024 * 1024; constexpr long long kFreeDiskMarginBytes = 64LL * 1024 * 1024; // A download whose length is unknown up front (chunked / encoded body) can // only reserve the margin when the response arrives; the disk is re-probed -// every this many streamed bytes so the cap above cannot eat the margin. +// before the first body byte and then every this many streamed bytes, each +// probe reserving this many bytes ahead, so the writes between two probes +// can never eat into the margin. constexpr long long kUnknownLengthFreeSpaceProbeBytes = 8LL * 1024 * 1024; } // namespace archive_limits diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index a97ac40d..c9122655 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -679,7 +679,7 @@ export class DownloadTask { } }; - let lastFreeSpaceProbeBytes = 0; + let nextFreeSpaceProbeAt = 0; const enqueueWrite = (data: ArrayBuffer) => { received += data.byteLength; if (!writeError && baseOffset + received > MAX_ARCHIVE_BYTES) { @@ -689,12 +689,16 @@ export class DownloadTask { `archive too large: exceeded ${MAX_ARCHIVE_BYTES}`, ); } + // 未知长度:响应到达时只能预留安全余量,所以首次写入前探测一次,之后 + // 每写满 PROBE 字节再探测,每次都预留接下来的 PROBE 字节——两次探测之间 + // 的写入永远吃不到余量(归档上限本身远大于余量)。失败保留 partial 供 + // 下次续传。 + const writtenBefore = received - data.byteLength; const probeFreeSpace = - totalAll <= 0 && - received - lastFreeSpaceProbeBytes >= - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; + totalAll <= 0 && writtenBefore >= nextFreeSpaceProbeAt; if (probeFreeSpace) { - lastFreeSpaceProbeBytes = received; + nextFreeSpaceProbeAt = + writtenBefore + UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; } writeQueue = writeQueue.then(async () => { if (!writer || writeError) { @@ -702,9 +706,10 @@ export class DownloadTask { } try { if (probeFreeSpace) { - // 未知长度:响应到达时只能预留安全余量,边收边重新探测磁盘 - // (归档上限本身远大于余量)。失败保留 partial 供下次续传。 - await ensureFreeSpace(params.targetFile, data.byteLength); + await ensureFreeSpace( + params.targetFile, + UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, + ); } await fileIo.write(writer.fd, data); } catch (error) { diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index d6b68ee6..e89f0dbd 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -145,8 +145,9 @@ @interface RCTPushyDownloader() @property (nonatomic, assign) BOOL discardPartial; @property (nonatomic, assign) int lastReportedPercentage; @property (nonatomic, assign) long long lastReportedBytes; -// receivedBytes at the last streaming free-space probe (unknown-length bodies) -@property (nonatomic, assign) long long lastFreeSpaceProbeBytes; +// receivedBytes at which the next streaming free-space probe is due +// (unknown-length bodies; 0 = probe before the first write) +@property (nonatomic, assign) long long nextFreeSpaceProbeAt; @end @implementation RCTPushyDownloader @@ -471,7 +472,7 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data if (!append) { [fileManager createFileAtPath:self.savePath contents:nil attributes:nil]; } - self.lastFreeSpaceProbeBytes = 0; + self.nextFreeSpaceProbeAt = 0; self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.savePath]; if (self.fileHandle == nil) { [self failWithDescription:@"cannot open download file for writing" code:-1]; @@ -522,15 +523,17 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data [dataTask cancel]; return; } - if (self.expectedTotal <= 0 - && self.receivedBytes - self.lastFreeSpaceProbeBytes - >= pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes) { + if (self.expectedTotal <= 0 && self.receivedBytes >= self.nextFreeSpaceProbeAt) { // Unknown/encoded length: the response-time check could only - // reserve the margin, so re-probe the disk as bytes stream in. The - // archive cap alone (512 MiB) is far more than the margin protects. - // The partial stays: a later attempt may find the space. - self.lastFreeSpaceProbeBytes = self.receivedBytes; - NSString *shortfall = RCTPushyFreeSpaceShortfall(self.savePath, data.length); + // reserve the margin, so probe before the first write and then every + // PROBE bytes, each probe reserving the next PROBE bytes — the writes + // between two probes can never eat into the margin. The archive cap + // alone (512 MiB) is far more than the margin protects. The partial + // stays: a later attempt may find the space. + self.nextFreeSpaceProbeAt = self.receivedBytes + + pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes; + NSString *shortfall = RCTPushyFreeSpaceShortfall( + self.savePath, pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes); if (shortfall != nil) { [self failWithDescription:shortfall code:-1]; [dataTask cancel]; From f6700d04840d39ce37ffb8a7ec972d7840d61a23 Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Fri, 4 Sep 2026 15:00:59 +0800 Subject: [PATCH 2/2] fix(download): reserve against the write, not the callback boundary Probe before any write that would run past the bytes reserved so far and reserve max(kUnknownLengthFreeSpaceProbeBytes, chunk) each time, so a single large callback buffer cannot cross the reservation unprobed. On Android a failed probe clears the Okio buffer before rethrowing: closing the sink would otherwise flush the just-read chunk onto the disk that failed the check. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0149oHt3QpNA3XNocSBVFNCh --- .../modules/update/DownloadTask.java | 30 ++++++++++++------- cpp/patch_core/archive_limits.h | 6 ++-- harmony/pushy/src/main/ets/DownloadTask.ts | 25 ++++++++-------- ios/RCTPushy/RCTPushyDownloader.mm | 30 ++++++++++--------- 4 files changed, 50 insertions(+), 41 deletions(-) diff --git a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java index 5ee393d4..14cd5e5c 100644 --- a/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java +++ b/android/src/main/java/cn/reactnative/modules/update/DownloadTask.java @@ -353,12 +353,12 @@ private boolean transferArchive( int currentPercentage = 0; long lastPostedBytes = baseOffset; // Unknown length: the response-time check could only reserve the - // margin, so the disk is probed before the first write and then - // every PROBE bytes, each probe reserving the next PROBE bytes — - // the writes between two probes can never eat into the margin - // (the archive cap alone is far more than the margin). A throw - // keeps the partial for a later attempt. - long nextFreeSpaceProbeAt = 0; + // margin, so the disk is probed before any write that would run + // past the bytes reserved so far, each probe reserving at least + // the next PROBE bytes (more when one chunk is larger) — no write + // can ever eat into the margin (the archive cap alone is far more + // than the margin). A throw keeps the partial for a later attempt. + long freeSpaceReservedUntil = 0; try ( BufferedSource source = body.source(); @@ -367,11 +367,19 @@ private boolean transferArchive( ) { while ((bytesRead = source.read(sink.buffer(), DOWNLOAD_CHUNK_SIZE)) != -1) { received += bytesRead; - if (totalAll <= 0 && received - bytesRead >= nextFreeSpaceProbeAt) { - nextFreeSpaceProbeAt = received - bytesRead - + ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; - ArchiveLimits.ensureFreeSpace( - writePath, ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES); + if (totalAll <= 0 && received > freeSpaceReservedUntil) { + long reserve = Math.max( + ArchiveLimits.UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, bytesRead); + freeSpaceReservedUntil = received - bytesRead + reserve; + try { + ArchiveLimits.ensureFreeSpace(writePath, reserve); + } catch (IOException e) { + // The chunk is still only buffered; closing the + // sink would flush it onto the disk that just + // failed the probe. + sink.buffer().clear(); + throw e; + } } sink.emit(); diff --git a/cpp/patch_core/archive_limits.h b/cpp/patch_core/archive_limits.h index 2f8135f0..65c57983 100644 --- a/cpp/patch_core/archive_limits.h +++ b/cpp/patch_core/archive_limits.h @@ -33,9 +33,9 @@ constexpr long long kMaxManifestBytes = 16LL * 1024 * 1024; constexpr long long kFreeDiskMarginBytes = 64LL * 1024 * 1024; // A download whose length is unknown up front (chunked / encoded body) can // only reserve the margin when the response arrives; the disk is re-probed -// before the first body byte and then every this many streamed bytes, each -// probe reserving this many bytes ahead, so the writes between two probes -// can never eat into the margin. +// before any write that would run past the bytes reserved so far, each +// probe reserving at least this many bytes ahead (a single larger chunk +// reserves its own size), so no write can ever eat into the margin. constexpr long long kUnknownLengthFreeSpaceProbeBytes = 8LL * 1024 * 1024; } // namespace archive_limits diff --git a/harmony/pushy/src/main/ets/DownloadTask.ts b/harmony/pushy/src/main/ets/DownloadTask.ts index c9122655..46af09cf 100644 --- a/harmony/pushy/src/main/ets/DownloadTask.ts +++ b/harmony/pushy/src/main/ets/DownloadTask.ts @@ -679,7 +679,7 @@ export class DownloadTask { } }; - let nextFreeSpaceProbeAt = 0; + let freeSpaceReservedUntil = 0; const enqueueWrite = (data: ArrayBuffer) => { received += data.byteLength; if (!writeError && baseOffset + received > MAX_ARCHIVE_BYTES) { @@ -689,16 +689,18 @@ export class DownloadTask { `archive too large: exceeded ${MAX_ARCHIVE_BYTES}`, ); } - // 未知长度:响应到达时只能预留安全余量,所以首次写入前探测一次,之后 - // 每写满 PROBE 字节再探测,每次都预留接下来的 PROBE 字节——两次探测之间 - // 的写入永远吃不到余量(归档上限本身远大于余量)。失败保留 partial 供 - // 下次续传。 + // 未知长度:响应到达时只能预留安全余量,所以任何会写过已预留字节数的 + // 写入之前先探测,每次至少预留接下来的 PROBE 字节(单个 chunk 更大时按 + // chunk 算)——任何写入都吃不到余量(归档上限本身远大于余量)。失败保留 + // partial 供下次续传。 const writtenBefore = received - data.byteLength; - const probeFreeSpace = - totalAll <= 0 && writtenBefore >= nextFreeSpaceProbeAt; + const probeFreeSpace = totalAll <= 0 && received > freeSpaceReservedUntil; + const reserve = Math.max( + UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, + data.byteLength, + ); if (probeFreeSpace) { - nextFreeSpaceProbeAt = - writtenBefore + UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES; + freeSpaceReservedUntil = writtenBefore + reserve; } writeQueue = writeQueue.then(async () => { if (!writer || writeError) { @@ -706,10 +708,7 @@ export class DownloadTask { } try { if (probeFreeSpace) { - await ensureFreeSpace( - params.targetFile, - UNKNOWN_LENGTH_FREE_SPACE_PROBE_BYTES, - ); + await ensureFreeSpace(params.targetFile, reserve); } await fileIo.write(writer.fd, data); } catch (error) { diff --git a/ios/RCTPushy/RCTPushyDownloader.mm b/ios/RCTPushy/RCTPushyDownloader.mm index e89f0dbd..ceb6bc03 100644 --- a/ios/RCTPushy/RCTPushyDownloader.mm +++ b/ios/RCTPushy/RCTPushyDownloader.mm @@ -145,9 +145,9 @@ @interface RCTPushyDownloader() @property (nonatomic, assign) BOOL discardPartial; @property (nonatomic, assign) int lastReportedPercentage; @property (nonatomic, assign) long long lastReportedBytes; -// receivedBytes at which the next streaming free-space probe is due -// (unknown-length bodies; 0 = probe before the first write) -@property (nonatomic, assign) long long nextFreeSpaceProbeAt; +// receivedBytes up to which free disk has been probed for (unknown-length +// bodies; 0 = probe before the first write) +@property (nonatomic, assign) long long freeSpaceReservedUntil; @end @implementation RCTPushyDownloader @@ -472,7 +472,7 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data if (!append) { [fileManager createFileAtPath:self.savePath contents:nil attributes:nil]; } - self.nextFreeSpaceProbeAt = 0; + self.freeSpaceReservedUntil = 0; self.fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.savePath]; if (self.fileHandle == nil) { [self failWithDescription:@"cannot open download file for writing" code:-1]; @@ -523,17 +523,19 @@ - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)data [dataTask cancel]; return; } - if (self.expectedTotal <= 0 && self.receivedBytes >= self.nextFreeSpaceProbeAt) { + if (self.expectedTotal <= 0 + && self.receivedBytes + (long long)data.length > self.freeSpaceReservedUntil) { // Unknown/encoded length: the response-time check could only - // reserve the margin, so probe before the first write and then every - // PROBE bytes, each probe reserving the next PROBE bytes — the writes - // between two probes can never eat into the margin. The archive cap - // alone (512 MiB) is far more than the margin protects. The partial - // stays: a later attempt may find the space. - self.nextFreeSpaceProbeAt = self.receivedBytes - + pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes; - NSString *shortfall = RCTPushyFreeSpaceShortfall( - self.savePath, pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes); + // reserve the margin, so probe before any write that would run past + // the bytes reserved so far, each probe reserving at least the next + // PROBE bytes (more when one callback carries a larger buffer) — no + // write can ever eat into the margin. The archive cap alone (512 + // MiB) is far more than the margin protects. The partial stays: a + // later attempt may find the space. + long long reserve = MAX(pushy::archive_limits::kUnknownLengthFreeSpaceProbeBytes, + (long long)data.length); + self.freeSpaceReservedUntil = self.receivedBytes + reserve; + NSString *shortfall = RCTPushyFreeSpaceShortfall(self.savePath, reserve); if (shortfall != nil) { [self failWithDescription:shortfall code:-1]; [dataTask cancel];