Skip to content

Commit 048a61b

Browse files
fingolfinclaude
andcommitted
Download: add a 'resume' option
A download interrupted halfway currently starts again from zero, which for a data set of any size is the difference between a retry and an afternoon. With 'resume' set and a target given, the curl and wget methods continue the partial file. The clean-up in 'Download' skips the target for the same reason: otherwise it would throw away the very thing the next attempt is meant to continue. Methods that cannot resume decline the request rather than proceeding to avoid destroying the partial file the resuming methods need. 'resume' is deliberately left unbound when the caller does not ask for it, rather than defaulted to false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3db5598 commit 048a61b

4 files changed

Lines changed: 125 additions & 5 deletions

File tree

doc/download.xml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,28 @@ The following components are supported.
6969
and the function writes the downloaded contents to this file;
7070
the returned record does not have a <C>result</C> component in this case.
7171
<P/>
72-
If the download fails then this file is not left behind.
72+
If the download fails then this file is not left behind,
73+
unless <C>resume</C> is set.
74+
</Item>
75+
<Mark><C>resume</C></Mark>
76+
<Item>
77+
If this component is bound and has the value <K>true</K>,
78+
and <C>target</C> is given,
79+
then a partially downloaded file is continued rather than fetched again,
80+
and it is kept if the download fails again.
81+
<P/>
82+
Methods that cannot resume decline the request, since they would discard
83+
the partial file that a method which can resume needs; currently the
84+
methods based on the external programs <C>curl</C> and <C>wget</C> resume.
85+
If none is available the download fails, and the caller can retry without
86+
<C>resume</C>.
87+
<P/>
88+
It is the caller's responsibility that an existing <C>target</C> really is
89+
a prefix of what <A>url</A> delivers; otherwise the two get concatenated.
90+
<P/>
91+
If the server does not support range requests then no data is lost:
92+
<C>wget</C> fetches the file again from the start, and <C>curl</C> fails
93+
and leaves the partial file alone.
7394
</Item>
7495
<Mark><C>verifyCert</C></Mark>
7596
<Item>

lib/download.gi

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ Add( Download_Methods, rec(
3636
download:= function( url, opt )
3737
local res;
3838

39+
if IsBound( opt.resume ) and opt.resume = true and
40+
IsBound( opt.target ) and IsString( opt.target ) then
41+
# Declining matters: this method would truncate the partial file that a
42+
# method which can resume needs.
43+
return rec( success:= false, error:= "no support for resuming" );
44+
fi;
45+
3946
opt:= ShallowCopy( opt );
4047
if not IsBound( opt.failOnError ) then
4148
opt.failOnError:= true;
@@ -61,6 +68,11 @@ Add( Download_Methods, rec(
6168
return rec( success:= false, error:= "protocol is not http" );
6269
elif IsBound( opt.maxTime ) and opt.maxTime <> 0 then
6370
return rec( success:= false, error:= "no support for given timeout" );
71+
elif IsBound( opt.resume ) and opt.resume = true and
72+
IsBound( opt.target ) and IsString( opt.target ) then
73+
# No range request, so this would overwrite the partial file that a
74+
# method which can resume needs.
75+
return rec( success:= false, error:= "no support for resuming" );
6476
fi;
6577

6678
# Split the URL after 'http://' into the authority and HTTP request target.
@@ -164,6 +176,10 @@ Add( Download_Methods, rec(
164176
else
165177
args:= [ "--quiet", "-O", "-", url ];
166178
fi;
179+
if IsBound( opt.resume ) and opt.resume = true and
180+
IsBound( opt.target ) and IsString( opt.target ) then
181+
Add( args, "-c" );
182+
fi;
167183
if IsBound( opt.verifyCert ) and opt.verifyCert = false then
168184
Add( args, "--no-check-certificate" );
169185
fi;
@@ -173,8 +189,10 @@ Add( Download_Methods, rec(
173189
code:= Process( DirectoryCurrent(), exec, InputTextNone(), outstream, args );
174190
CloseStream( outstream );
175191
if code <> 0 then
176-
# wget may have created the target file; try to remove it
192+
# wget may have created the target file; try to remove it, unless the
193+
# caller wants to resume from what is there
177194
if IsBound( opt.target ) and IsString( opt.target ) and
195+
not ( IsBound( opt.resume ) and opt.resume = true ) and
178196
IsExistingFile( opt.target ) and RemoveFile( opt.target ) <> true then
179197
Error( "Download cannot remove unwanted file ", opt.target );
180198
fi;
@@ -211,6 +229,11 @@ Add( Download_Methods, rec(
211229
else
212230
Add( args, "-" );
213231
fi;
232+
if IsBound( opt.resume ) and opt.resume = true and
233+
IsBound( opt.target ) and IsString( opt.target ) then
234+
Add( args, "-C" );
235+
Add( args, "-" );
236+
fi;
214237
if IsBound( opt.maxTime ) and IsPosInt( opt.maxTime ) then
215238
Add( args, "--max-time" );
216239
Add( args, opt.maxTime );
@@ -275,8 +298,10 @@ InstallMethod( Download,
275298
fi;
276299
# A failed method may have left a partial or bogus target file behind.
277300
# Remove it here, so that the guarantee holds for every method,
278-
# including ones added to 'Download_Methods' from outside.
301+
# including ones added to 'Download_Methods' from outside -- but not
302+
# when resuming, where the partial file is the whole point.
279303
if IsBound( opt.target ) and IsString( opt.target ) and
304+
not ( IsBound( opt.resume ) and opt.resume = true ) and
280305
IsExistingFile( opt.target ) then
281306
RemoveFile( opt.target );
282307
fi;

tst/download.tst

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#@local meths, i, urls, pair, url, expected, res1, good1, n, file, res2, good2, contents, r, res3, good3, bad, server, baseurl, iometh, opt, oldpref
1+
#@local meths, i, urls, pair, url, expected, res1, good1, n, file, res2, good2, contents, r, res3, good3, bad, server, baseurl, iometh, opt, oldpref, resumers, name
22
############################################################################
33
##
44
#W download.tst Utils Package Thomas Breuer
@@ -164,6 +164,55 @@ true
164164
gap> IsExistingFile( file );
165165
false
166166

167+
## 'resume' continues a partial file rather than fetching it again. The
168+
## test server answers a Range request with the remainder in upper case, so
169+
## a resumed download is distinguishable from a restarted one.
170+
gap> resumers:= Filtered( meths, r -> r.name in [ "via curl", "via wget" ] );;
171+
gap> for r in resumers do
172+
> FileString( file, "abcde" );;
173+
> res1:= r.download( Concatenation( baseurl, "/resumable" ),
174+
> rec( target:= file, resume:= true ) );
175+
> if res1.success <> true then
176+
> Print( "resume failed for ", r.name, ": ", res1.error, "\n" );
177+
> elif StringFile( file ) <> "abcdeFGHIJKLMNOPQRST" then
178+
> Print( "did not resume for ", r.name, ": ", StringFile( file ), "\n" );
179+
> fi;
180+
> RemoveFile( file );
181+
> od;
182+
183+
## Without 'resume' the target is replaced, not appended to.
184+
gap> FileString( file, "abcde" );;
185+
gap> res1:= Download( Concatenation( baseurl, "/resumable" ),
186+
> rec( target:= file ) );;
187+
gap> StringFile( file );
188+
"abcdefghijklmnopqrst"
189+
190+
## Methods that cannot resume decline, rather than discarding the partial
191+
## file that a method which can resume needs.
192+
gap> iometh.download( Concatenation( baseurl, "/resumable" ),
193+
> rec( target:= file, resume:= true ) ).error;
194+
"no support for resuming"
195+
196+
## With 'resume', a failed download keeps the partial file to continue from.
197+
## Without this, the clean-up in 'Download' would throw away the very thing
198+
## the next attempt is meant to continue.
199+
gap> FileString( file, "abcde" );;
200+
gap> res1:= Download( Concatenation( baseurl, "/missing" ),
201+
> rec( target:= file, resume:= true ) );;
202+
gap> res1.success;
203+
false
204+
gap> StringFile( file );
205+
"abcde"
206+
gap> RemoveFile( file );;
207+
208+
## 'resume' is only declined when it is actually requested: a method must
209+
## not be skipped merely because the component is present.
210+
gap> res1:= Download( Concatenation( baseurl, "/file" ),
211+
> rec( target:= file, resume:= false ) );;
212+
gap> res1.success;
213+
true
214+
gap> RemoveFile( file );;
215+
167216
## test errors and redirects
168217
gap> res1:= Download( Concatenation( baseurl, "/missing" ) );;
169218
gap> res1.success = false;

tst/http-server.g

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
##
55

66
BindGlobal( "UTILS_HandleHTTPTestRequest", function( listener, socket )
7-
local connection, line, parts, uri, body, status, location;
7+
local connection, line, parts, uri, body, status, location, range, from;
88

99
IO_close( listener );
1010
connection:= IO_WrapFD( socket, IO.DefaultBufSize, IO.DefaultBufSize );
@@ -16,8 +16,12 @@ BindGlobal( "UTILS_HandleHTTPTestRequest", function( listener, socket )
1616
fi;
1717
uri:= parts[2];
1818

19+
range:= fail;
1920
repeat
2021
line:= IO_ReadLine( connection );
22+
if IsString( line ) and StartsWith( LowercaseString( line ), "range:" ) then
23+
range:= line;
24+
fi;
2125
until line = fail or line = "" or line = "\n" or line = "\r\n";
2226

2327
body:= "download test response\n";
@@ -32,6 +36,27 @@ BindGlobal( "UTILS_HandleHTTPTestRequest", function( listener, socket )
3236
body:= "";
3337
status:= "302 Found";
3438
location:= "Location: /success\r\n";
39+
elif StartsWith( uri, "/resumable" ) then
40+
# Answer a 'Range: bytes=N-' request with the remainder, but in upper
41+
# case, so that a test can tell a resumed download from a restarted one.
42+
body:= "abcdefghijklmnopqrst";
43+
if range <> fail then
44+
from:= Int( Filtered( range, c -> c in "0123456789" ) );
45+
if from <> fail and 0 < from and from < Length( body ) then
46+
IO_Write( connection,
47+
"HTTP/1.1 206 Partial Content\r\n",
48+
"Content-Type: text/plain\r\n",
49+
"Content-Range: bytes ", String( from ), "-",
50+
String( Length( body ) - 1 ), "/", String( Length( body ) ),
51+
"\r\n",
52+
"Content-Length: ", String( Length( body ) - from ), "\r\n",
53+
"Connection: close\r\n\r\n",
54+
UppercaseString( body{ [ from+1 .. Length( body ) ] } ) );
55+
IO_Flush( connection );
56+
IO_Close( connection );
57+
IO_exit( 0 );
58+
fi;
59+
fi;
3560
fi;
3661

3762
IO_Write( connection,

0 commit comments

Comments
 (0)