Skip to content

feat(network): add resume support to network.download#517

Open
YairDaniel123 wants to merge 1 commit into
Otzaria:devfrom
YairDaniel123:feature/resume-download
Open

feat(network): add resume support to network.download#517
YairDaniel123 wants to merge 1 commit into
Otzaria:devfrom
YairDaniel123:feature/resume-download

Conversation

@YairDaniel123

Copy link
Copy Markdown

Add
esume parameter to downloadToPath and _fetchFollowingAllowedRedirects to support resuming interrupted downloads via HTTP Range requests.

When
esume: true is passed and a partial file exists at destPath:

  • Sends Range: bytes=N- header (N = existing file size)
  • If server returns 206 Partial Content: appends to existing file
  • If server returns 200 (Range ignored): overwrites from scratch
  • On failure: partial file is preserved for the next attempt

Also update plugin_bridge_adapter.dart to extract
esume from RPC args and forward it to downloadToPath.

Motivation: large files (e.g. 164 MB ZIPs from GitHub Releases) can stall mid-download. Without resume, each retry starts from byte 0. With resume, retries continue from the last received byte, reducing total transfer time and making large-file downloads reliable despite transient CDN stalls.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for resuming file downloads by adding a resume parameter to the plugin bridge adapter and file download service. When enabled, the service checks for an existing partial file and requests the remaining bytes using the Range header. The review feedback highlights two important improvements: handling the 416 Range Not Satisfiable status code gracefully when a download is already complete, and safely parsing the resume argument as a boolean to prevent potential runtime type errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 147 to +158
final response = await _fetchFollowingAllowedRedirects(
downloadUri,
isAllowed,
isRedirectAllowed,
rangeStart: existingBytes,
);

final outFile = File(destPath);
await outFile.parent.create(recursive: true);
// 206 = השרת כיבד את ה-Range → צרף לקובץ קיים; 200 = כתוב מחדש
final shouldAppend = existingBytes > 0 && response.statusCode == 206;
final sink = outFile.openWrite(
mode: shouldAppend ? FileMode.append : FileMode.write,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When resuming a download that is already fully completed (i.e., existingBytes is exactly equal to the total file size on the server), sending a Range: bytes=N- request will cause compliant servers to return a 416 Range Not Satisfiable status code.

Currently, this will result in a failed download exception. We can make this much more robust by intercepting the 416 response, parsing the Content-Range header to check the total file size, and if it matches existingBytes, gracefully treating the download as successfully completed.

    final response = await _fetchFollowingAllowedRedirects(
      downloadUri,
      isAllowed,
      isRedirectAllowed,
      rangeStart: existingBytes,
    );

    if (response.statusCode == 416) {
      final contentRange = response.headers['content-range'];
      if (contentRange != null) {
        final match = RegExp(r'bytes\s+(?:\*|\d+-\d+)/(\d+)').firstMatch(contentRange);
        if (match != null) {
          final totalLength = int.tryParse(match.group(1) ?? '');
          if (totalLength == existingBytes) {
            await response.stream.timeout(_stallTimeout).drain<void>();
            return PluginFileDownloadResult(outFile.path, p.basename(outFile.path));
          }
        }
      }
      await response.stream.timeout(_stallTimeout).drain<void>();
      throw Exception('שגיאה בהורדת הקובץ (416)');
    }

    // 206 = השרת כיבד את ה-Range → צרף לקובץ קיים; 200 = כתוב מחדש
    final shouldAppend = existingBytes > 0 && response.statusCode == 206;
    final sink = outFile.openWrite(
      mode: shouldAppend ? FileMode.append : FileMode.write,
    );

// תיקיית ההורדות. הנתיב חייב להיות בתוך תיקייה שהמשתמש אישר דרך
// ui.pickFolder — אותו גבול אבטחה של פעולות ה-fs.
final destPath = args['destPath'] as String?;
final resume = args['resume'] as bool? ?? false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using args['resume'] as bool? can throw a TypeError if the plugin passes a non-boolean value (e.g., a string or integer) for the resume parameter. It is safer to use args['resume'] == true to avoid runtime crashes.

Suggested change
final resume = args['resume'] as bool? ?? false;
final resume = args['resume'] == true;

@Y-PLONI

Y-PLONI commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

תודה על ה-PR! הכיוון נכון והצורך אמיתי (קבצי ZIP גדולים שנתקעים באמצע הורדה). עברתי על הקוד לעומק, וכרגע יש כמה נקודות חוסמות לפני מיזוג — בעיקר משום שכבר קיים בריפו מימוש resume בוגר שמטפל נכון בכל הקצוות שה-PR מפספס.

🔴 קריטי

1. אין אימות Content-Range בתגובת 206 → שחיתות קובץ שקטה

ב-downloadToPath הקוד מצרף את גוף ה-206 באופן עיוור:

final shouldAppend = existingBytes > 0 && response.statusCode == 206;

שרת/CDN רשאי להחזיר 206 שמתחיל ב-offset שונה מ-existingBytes. הצירוף ייצר ZIP פגום בלי שום שגיאה — המשתמש יקבל "הצלחה" וקובץ מקולקל. חובה לפענח את Content-Range ולאמת שה-offset תואם לפני append.

2. תגובת 416 על קובץ שכבר הושלם → כשל קבוע

אם הקובץ כבר ירד במלואו וקוראים שוב עם resume:true, השרת מחזיר 416 Range Not Satisfiable, והקוד זורק Exception('שגיאה בהורדת הקובץ (416)'). כלומר קובץ שלם לעולם לא יוכל "להצליח" — וזה דווקא התרחיש המרכזי של ניסיונות חוזרים. צריך לטפל ב-416 כהצלחה (הקובץ שלם).

3. אין בדיקת שלמות → resume מדווח "הצלחה" על קובץ חלקי

השירות מחזיר הצלחה ברגע שהזרם נסגר נקי, בלי לוודא שהתקבל כל הקובץ (אין השוואה מול Content-Length/total). אם החיבור נסגר באמצע 206, התוסף יחשוב שההורדה הושלמה. זה קיים גם בלי resume, אבל resume מחמיר אותו — הוא נועד בדיוק לחיבורים רעועים.

⚠️ ארכיטקטורה — כבר קיים מימוש resume בוגר בריפו

ב-lib/empty_library/bloc/empty_library_bloc.dart (פונקציית _downloadAsset) כבר קיים מימוש resume שמטפל נכון בכל מה שחסר כאן:

מקרה _downloadAsset הקיים ה-PR
416 (קובץ שלם) return; — מזהה שהקובץ שלם throw — כשל ❌
200 (אין Range) מוחק חלקי ומתחיל מחדש FileMode.write
206 + Content-Range מאמת serverStart == alreadyDownloaded append עיוור ❌
בדיקת שלמות alreadyDownloaded >= compressedSize אין ❌

עדיף לחלץ את הלוגיקה הקיימת לפונקציה משותפת, או לפחות להעתיק ממנה את הטיפול ב-416 וב-Content-Range.

🟡 נוסף

  • אין טסטים — חובה בפרויקט. תשתית ה-MockClient ב-test/plugins/services/plugin_file_download_service_test.dart כבר מוכנה ומאפשרת לבדוק בקלות 206/200/416/Content-Range לא תואם.
  • footgun ב-destPath: downloadToPath רגיל דורס; עם resume:true הוא מצרף. אם נשאר בנתיב קובץ ישן/שונה → צירוף עליו = שחיתות. אין If-Range/ETag שמקשר את החלקי ל-URL.
  • קל: ה-Range נשלח גם לבקשות ה-redirect הביניים (מיותר, עדיף רק לבקשה הסופית); התיעוד בשורה ~117 ("קובץ קיים נדרס") כבר לא מדויק במצב resume.

מבחינת אבטחה אין רגרסיה — בדיקות ה-allowlist, ה-redirects וה-path validation לא נפגעו.

לסיכום: הרעיון טוב, אבל לפני מיזוג נדרש לפחות (1) טיפול ב-416 כהצלחה, (2) אימות Content-Range לפני append, (3) טסטים. ורצוי לאחד עם המימוש הקיים ב-empty_library_bloc במקום לשכפל. 🙏

Add 
esume parameter to downloadToPath and _fetchFollowingAllowedRedirects
to support resuming interrupted downloads via HTTP Range requests.

When 
esume: true is passed and a partial file exists at destPath:
- Sends Range: bytes=N- header (N = existing file size)
- If server returns 206 Partial Content: appends to existing file
- If server returns 200 (Range ignored): overwrites from scratch
- On failure: partial file is preserved for the next attempt

Also update plugin_bridge_adapter.dart to extract 
esume from RPC args
and forward it to downloadToPath.

Motivation: large files (e.g. 164 MB ZIPs from GitHub Releases) can stall
mid-download. Without resume, each retry starts from byte 0. With resume,
retries continue from the last received byte, reducing total transfer time
and making large-file downloads reliable despite transient CDN stalls.
@palmoni5 palmoni5 force-pushed the feature/resume-download branch from 957c9ee to cc04f2f Compare July 1, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants