From 1143f0987cc0843eb95179b549943041adeebfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kl=C3=B6tzke?= Date: Sat, 27 Dec 2025 16:41:39 +0100 Subject: [PATCH 1/2] url: handle OSError on download Reading from the socket might cause an OSError (e.g. "ConnectionResetError: [Errno 104] Connection reset by peer"). We must catch such errors and gracefully handle them in the retry loop. On the other hand, writing to the local file can cause OSError exceptions too. This should not happen but in case of "disk full" errors, we can probably stop retrying. Fixes #685. --- pym/bob/scm/url.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pym/bob/scm/url.py b/pym/bob/scm/url.py index d1d9111a..d95b254f 100644 --- a/pym/bob/scm/url.py +++ b/pym/bob/scm/url.py @@ -12,6 +12,7 @@ from http.client import HTTPException from abc import abstractmethod import asyncio +import errno import concurrent.futures.process import contextlib import hashlib @@ -512,7 +513,7 @@ def _download(self, url, destination, mode): if "content-length" in rsp.info(): expected = int(rsp.info()["Content-Length"]) if expected > read: - return False, "Response too short: {} < {} (bytes)".format(read, expected) + return False, "Response too short: {} < {} (bytes)".format(read, expected), False # Atomically move file to destination. Set mode to 0600 to # retain Bob 0.15 behaviour if no explicit mode was configured. @@ -522,12 +523,14 @@ def _download(self, url, destination, mode): except urllib.error.HTTPError as e: if e.code != 304: - return False, "HTTP error {}: {}".format(e.code, e.reason) + return False, "HTTP error {}: {}".format(e.code, e.reason), False else: # HTTP 304 Not modifed -> local file up-to-date - return False, None + return False, None, False except HTTPException as e: - return False, "HTTP error: " + str(e) + return False, "HTTP error: " + str(e), False + except OSError as e: + return False, "Failed to download: " + str(e), e.errno == errno.ENOSPC finally: if tmpFileName is not None: os.remove(tmpFileName) @@ -535,7 +538,7 @@ def _download(self, url, destination, mode): # to prevent ugly backtraces when user presses ctrl+c. signal.signal(signal.SIGINT, signal.SIG_DFL) - return True, None + return True, None, False async def _fetch(self, invoker, url, destination, mode): if url.scheme in ['', 'file']: @@ -565,9 +568,11 @@ async def _fetch(self, invoker, url, destination, mode): while True: invoker.trace("", url.geturl(), ">", destination, "retires:", retries) try: - updated, err = await invoker.runInExecutor(UrlScm._download, self, url, destination, mode) + updated, err, fatal = await invoker.runInExecutor(UrlScm._download, self, url, destination, mode) if err: - if retries == 0: + if fatal: + invoker.fail(err) + elif retries == 0: return False, err else: invoker.warn(err) From f1913a7c8eae67359b4f967cdb6dd2002f17e0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Kl=C3=B6tzke?= Date: Sat, 27 Dec 2025 16:50:45 +0100 Subject: [PATCH 2/2] test: test disk full errors in url scm --- test/unit/test_input_urlscm.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/test/unit/test_input_urlscm.py b/test/unit/test_input_urlscm.py index a2957c00..475eece2 100644 --- a/test/unit/test_input_urlscm.py +++ b/test/unit/test_input_urlscm.py @@ -7,6 +7,9 @@ from unittest import TestCase, skipIf from unittest.mock import MagicMock, patch import asyncio +import contextlib +import errno +import concurrent.futures import os, stat import subprocess import shutil @@ -54,8 +57,9 @@ def __exit__(self, exception_type, exception_value, exception_traceback): class UrlScmExecutor: - def invokeScm(self, workspace, scm, switch=False, oldScm=None, workspaceCreated=False): - executor = getProcessPoolExecutor() + def invokeScm(self, workspace, scm, switch=False, oldScm=None, workspaceCreated=False, executor=None): + if executor is None: + executor = getProcessPoolExecutor() try: spec = MagicMock(workspaceWorkspacePath=workspace, envWhiteList=set()) invoker = Invoker(spec, False, True, True, True, True, False, @@ -341,6 +345,27 @@ def testNoResponse(self): with self.assertRaises(InvocationError): self.invokeScm(workspace, scm) + @patch('signal.signal') + def testDownloadDiskFull(self, signalMock): + """Full disk directly terminates downloads""" + tmp = MagicMock() + tmp.name = None + tmp.write.side_effect = OSError(errno.ENOSPC, "Disk full") + ntf = MagicMock(return_value=contextlib.nullcontext(enter_result=tmp)) + + with (HttpServerMock(self.dir) as srv, + TemporaryWorkspace() as workspace, + patch('tempfile.NamedTemporaryFile', ntf)): + scm = self.createUrlScm({ + "url" : "http://localhost:{}/test.txt".format(srv.port), + "retries" : 3 + }) + + with self.assertRaises(InvocationError): + self.invokeScm(workspace, scm, executor=concurrent.futures.ThreadPoolExecutor()) + + self.assertEqual(srv.getRequests, 1, "URL SCM does not retry on disk full errors") + class TestExtraction(TestCase): @classmethod