Skip to content

Commit 38fff0e

Browse files
committed
gh-48: Report slow downloads
1 parent 0e7ea19 commit 38fff0e

2 files changed

Lines changed: 161 additions & 5 deletions

File tree

src/manage/urlutils.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def __init__(self, url, method="GET", headers={}, outfile=None):
148148
self.password = None
149149
self.outfile = Path(outfile) if outfile else None
150150
self.proxy_settings = _proxy_settings_from_env()
151+
self._download_start = time.monotonic()
151152
self._on_progress = None
152153
self._on_auth_request = None
153154
self._on_cancel = None
@@ -158,6 +159,15 @@ def __str__(self):
158159
def on_progress(self, progress):
159160
if self._on_progress:
160161
self._on_progress(progress)
162+
elif (self._download_start is not None
163+
and progress is not None and progress < 100
164+
and time.monotonic() - self._download_start > 5):
165+
LOGGER.warn(
166+
"Downloading %s is taking some time. Please continue to wait, "
167+
"or press Ctrl+C to abort.",
168+
self,
169+
)
170+
self._download_start = None
161171

162172
def on_auth_request(self, url=None):
163173
if url is None:
@@ -236,7 +246,7 @@ def _bits_urlretrieve(request):
236246
# Returned HTTP status 404 (0x194)
237247
raise FileNotFoundError() from ex
238248
raise
239-
if progress > last_progress:
249+
if progress > last_progress or not request._on_progress:
240250
request.on_progress(progress)
241251
last_progress = progress
242252
time.sleep(0.1)
@@ -320,6 +330,8 @@ def _urllib_urlopen(request):
320330
raise FileNotFoundError from ex
321331
else:
322332
raise
333+
if not request._on_progress:
334+
request.on_progress(0)
323335
with r:
324336
data = r.read()
325337
request.on_progress(100)
@@ -349,6 +361,8 @@ def _urllib_urlretrieve(request):
349361
r = urlopen(req)
350362
else:
351363
raise
364+
if not request._on_progress:
365+
request.on_progress(0)
352366
with r:
353367
progress = 0
354368
try:
@@ -467,20 +481,23 @@ def _powershell_urlretrieve(request):
467481
stderr=subprocess.STDOUT,
468482
) as p:
469483
request.on_progress(0)
470-
start = time.time()
484+
start = time.monotonic()
485+
timeout = 10.0 if request._on_progress else 1.0
471486
while True:
472487
try:
473488
try:
474-
out = p.communicate(b'', timeout=10.0)[0].decode("utf-8", "replace")
489+
out = p.communicate(b'', timeout=timeout)[0].decode("utf-8", "replace")
475490
if '<S S="Error">Invoke-WebRequest' in out:
476491
raise RuntimeError("Powershell download failed:" + out)
477492
request.on_progress(100)
478493
LOGGER.debug("PowerShell Output: %s", out)
479494
return
480495
except subprocess.TimeoutExpired:
481-
if not request.outfile.exists():
496+
request.on_progress(0)
497+
elapsed = time.monotonic() - start
498+
if not request.outfile.exists() and elapsed >= 10:
482499
# Suppress the original exception to avoid leaking the command
483-
raise subprocess.TimeoutExpired(powershell, int(time.time() - start)) from None
500+
raise subprocess.TimeoutExpired(powershell, int(elapsed)) from None
484501
except:
485502
p.terminate()
486503
out = p.communicate()[0]

tests/test_urlutils.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,145 @@ def local_withauth(localserver):
295295
yield req
296296

297297

298+
def test_slow_download_warning(monkeypatch, assert_log):
299+
now = [10.0]
300+
monkeypatch.setattr(UU.time, "monotonic", lambda: now[0])
301+
302+
def urlopen(request):
303+
request.on_progress(0)
304+
now[0] += 5
305+
request.on_progress(50)
306+
assert_log(assert_log.not_logged("Downloading .+ is taking some time.+"))
307+
308+
now[0] += 0.1
309+
request.on_progress(50)
310+
request.on_progress(75)
311+
return b"download"
312+
313+
monkeypatch.setattr(UU, "ENABLE_WINHTTP", True)
314+
monkeypatch.setattr(UU, "_winhttp_urlopen", urlopen)
315+
316+
result = UU.urlopen("https://user:pass@example.com/index.json")
317+
318+
assert result == b"download"
319+
assert_log(
320+
(
321+
"Downloading %s is taking some time. Please continue to wait, "
322+
"or press Ctrl\\+C to abort.",
323+
["https://example.com/index.json"],
324+
),
325+
assert_log.end_of_log(),
326+
)
327+
328+
329+
def test_slow_download_warning_not_emitted_on_completion(monkeypatch, assert_log):
330+
now = [10.0]
331+
monkeypatch.setattr(UU.time, "monotonic", lambda: now[0])
332+
request = UU._Request("https://example.com/index.json")
333+
334+
now[0] += 5.1
335+
request.on_progress(100)
336+
337+
assert_log(assert_log.not_logged("Downloading .+ is taking some time.+"))
338+
339+
340+
def test_slow_download_warning_suppressed_by_progress(monkeypatch, assert_log):
341+
now = [10.0]
342+
monkeypatch.setattr(UU.time, "monotonic", lambda: now[0])
343+
request = UU._Request("https://example.com/index.json")
344+
progress = []
345+
request._on_progress = progress.append
346+
347+
now[0] += 5.1
348+
request.on_progress(50)
349+
350+
assert progress == [50]
351+
assert_log(assert_log.not_logged("Downloading .+ is taking some time.+"))
352+
353+
354+
def test_powershell_slow_download_warning(monkeypatch, assert_log, tmp_path):
355+
import shutil
356+
import subprocess
357+
358+
now = [10.0]
359+
monkeypatch.setattr(UU.time, "monotonic", lambda: now[0])
360+
monkeypatch.setattr(shutil, "which", lambda _: "powershell.exe")
361+
362+
class Process:
363+
def __init__(self, *args, **kwargs):
364+
pass
365+
366+
def __enter__(self):
367+
return self
368+
369+
def __exit__(self, *exc_info):
370+
pass
371+
372+
def communicate(self, _=None, timeout=None):
373+
if timeout is None:
374+
return (b"",)
375+
now[0] += timeout
376+
if now[0] <= 16:
377+
raise subprocess.TimeoutExpired("powershell.exe", timeout)
378+
return (b"",)
379+
380+
def terminate(self):
381+
pass
382+
383+
monkeypatch.setattr(subprocess, "Popen", Process)
384+
385+
request = UU._Request("https://user:pass@example.com/index.json")
386+
request.outfile = tmp_path / "index.json"
387+
388+
UU._powershell_urlretrieve(request)
389+
390+
warning = (
391+
"Downloading %s is taking some time. Please continue to wait, "
392+
"or press Ctrl+C to abort."
393+
)
394+
assert_log(assert_log.skip_until(
395+
warning.replace("+", "\\+"),
396+
["https://example.com/index.json"],
397+
))
398+
assert sum(1 for msg, _ in assert_log if msg == warning) == 1
399+
400+
401+
def test_bits_slow_download_warning(monkeypatch, assert_log, tmp_path):
402+
bits = object()
403+
job = object()
404+
now = [10.0]
405+
progress = iter([0] * 52 + [100])
406+
407+
def sleep(delay):
408+
now[0] += delay
409+
410+
monkeypatch.setattr(UU.time, "monotonic", lambda: now[0])
411+
monkeypatch.setattr(UU.time, "sleep", sleep)
412+
monkeypatch.setattr(_native, "coinitialize", lambda: None, raising=False)
413+
monkeypatch.setattr(_native, "bits_connect", lambda: bits, raising=False)
414+
monkeypatch.setattr(_native, "bits_begin", lambda *a, **k: job, raising=False)
415+
monkeypatch.setattr(_native, "bits_cancel", lambda *a: None, raising=False)
416+
monkeypatch.setattr(_native, "bits_get_progress", lambda *a: next(progress), raising=False)
417+
monkeypatch.setattr(_native, "bits_retry_with_auth", lambda *a: None, raising=False)
418+
monkeypatch.setattr(_native, "bits_find_job", lambda *a: None, raising=False)
419+
monkeypatch.setattr(_native, "bits_serialize_job", lambda *a: b"job-id", raising=False)
420+
421+
request = UU._Request("https://user:pass@example.com/download.zip")
422+
request.outfile = tmp_path / "download.zip"
423+
424+
UU._bits_urlretrieve(request)
425+
426+
warning = (
427+
"Downloading %s is taking some time. Please continue to wait, "
428+
"or press Ctrl+C to abort."
429+
)
430+
assert_log(assert_log.skip_until(
431+
warning.replace("+", "\\+"),
432+
["https://example.com/download.zip"],
433+
))
434+
assert sum(1 for msg, _ in assert_log if msg == warning) == 1
435+
436+
298437
def test_urllib_urlretrieve(local_128kb, tmp_path):
299438
local_128kb.outfile = dest = tmp_path / "read.txt"
300439
progress = local_128kb.progress

0 commit comments

Comments
 (0)