diff --git a/src/main/java/com/sheepit/client/Client.java b/src/main/java/com/sheepit/client/Client.java index c51a9e7..05955dd 100644 --- a/src/main/java/com/sheepit/client/Client.java +++ b/src/main/java/com/sheepit/client/Client.java @@ -37,7 +37,6 @@ import java.util.Locale; import java.util.Observable; import java.util.Observer; import java.util.Optional; -import java.util.Random; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ArrayBlockingQueue; @@ -63,7 +62,6 @@ import com.sheepit.client.exception.SheepItExceptionSessionDisabled; import com.sheepit.client.exception.SheepItExceptionSessionDisabledDenoisingNotSupported; import com.sheepit.client.exception.SheepItServerDown; import com.sheepit.client.hardware.cpu.CPU; -import com.sheepit.client.hardware.hwid.HWIdentifier; import com.sheepit.client.os.OS; import lombok.AllArgsConstructor; @@ -90,8 +88,6 @@ import okhttp3.HttpUrl; private boolean suspended; private boolean shuttingdown; - private int maxDownloadFileAttempts = 5; - private int uploadQueueSize; private long uploadQueueVolume; private int noJobRetryIter; @@ -865,7 +861,16 @@ import okhttp3.HttpUrl; int total = ajob_.getArchiveChunks().size(); for (int i = 0; i < total; i++) { Chunk chunk = ajob_.getArchiveChunks().get(i); - Error.Type ret = this.downloadFile(ajob_, ajob_.getRequiredProjectChunkPath(chunk.getId()), chunk.getMd5(), String.format(LOCALE, "%s?chunk=%s", this.server.getPage("download-chunk"), chunk.getId()), String.format(LOCALE, "chunk %d/%d", i + 1, total)); + DownloadManager downloadManager = new DownloadManager( + this.server, + this.gui, + this.log, + String.format(LOCALE, "chunk %d/%d", i + 1, total), + ajob_.getRequiredProjectChunkPath(chunk.getId()), + chunk.getMd5(), + String.format(LOCALE, "%s?chunk=%s", this.server.getPage("download-chunk"), chunk.getId()) + ); + Error.Type ret = downloadManager.download(); if (ret != Type.OK) { return ret; } @@ -874,134 +879,15 @@ import okhttp3.HttpUrl; } protected Error.Type downloadExecutable(Job ajob) throws SheepItException { - return this.downloadFile(ajob, ajob.getRequiredRendererArchivePath(), ajob.getRendererMD5(), - String.format(LOCALE, "%s?job=%s", this.server.getPage("download-binary"), ajob.getId()), "renderer"); - } - - private Error.Type downloadFile(Job ajob, String local_path, String md5_server, String url, String download_type) throws SheepItException { - File local_path_file = new File(local_path); - String update_ui = "Downloading " + download_type; - - int remaining = 1800000; // 30 minutes max timeout - - try { - // If the client is using a shared cache then introduce some random delay to minimise race conditions on the partial file creation on multiple - // instances of a client (when started with a script or rendering a recently downloaded scene) - if (configuration.getSharedDownloadsDirectory() != null) { - Thread.sleep((new Random().nextInt(9) + 1) * 1000); - } - - // For a maximum of 30 minutes - do { - // if the binary or scene already exists in the cache - if (local_path_file.exists()) { - this.gui.status("Reusing cached " + download_type); - return Type.OK; - } - // if the binary or scene is being downloaded by another client - else if (new File(local_path + ".partial").exists()) { - // Wait and check every second for file download completion but only update the GUI every 10 seconds to minimise CPU load - if (remaining % 10000 == 0) { - this.gui.status(String.format("Another client is downloading the %s. Cancel in %dmin %ds", - download_type, - TimeUnit.MILLISECONDS.toMinutes(remaining), - TimeUnit.MILLISECONDS.toSeconds(remaining) - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(remaining)) - )); - } - } - else { - // The file doesn't yet exist not is being downloaded by another client, so immediately create the file with zero bytes to allow early - // detection by other concurrent clients and start downloading process - try { - File file = new File(local_path + ".partial"); - file.createNewFile(); - file.deleteOnExit(); // if the client crashes, the temporary file will be removed - } catch (IOException e) { - StringWriter sw = new StringWriter(); - e.printStackTrace(new PrintWriter(sw)); - this.log.error("Client::DownloadFile Unable to create .partial temp file for binary/scene " + local_path); - this.log.error("Client::DownloadFile Exception " + e + " stacktrace " + sw.toString()); - } - - break; - } - - // Reduce 1 second the waiting time - Thread.sleep(1000); - remaining -= 1000; - } while (remaining > 0); - } - catch (InterruptedException e) { - log.debug("Error in the thread wait. Exception " + e.getMessage()); - } - finally { - // If we have reached the timeout (30 minutes trying to download the client) delete the partial downloaded copy and try to download again - if (remaining <= 0) { - log.debug("ERROR while waiting for download to finish in another client. Deleting the partial file and downloading a fresh copy now!."); - new File(local_path + ".partial").delete(); - } - } - - this.gui.status(String.format("Downloading %s", download_type)); - - // must download the archive - Error.Type ret = this.server.HTTPGetFile(url, local_path, this.gui, update_ui); - - if (ret == Type.RENDERER_KILLED_BY_SERVER || ret == Type.RENDERER_KILLED_BY_USER_OVER_TIME || ret == Type.RENDERER_KILLED_BY_USER) { - return ret; - } - - // Try to check the download file even if a download error has occurred (MD5 file check will delete the file if partially downloaded) - boolean md5_check = this.checkFile(ajob, local_path, md5_server); - int attempts = 1; - - while ((ret != Error.Type.OK || md5_check == false) && attempts < this.maxDownloadFileAttempts) { - if (ret != Error.Type.OK) { - this.gui.error(String.format("Unable to download %s (error %s). Retrying now", download_type, ret)); - this.log.debug("Client::downloadFile problem with Server.HTTPGetFile (return: " + ret + ") removing local file (path: " + local_path + ")"); - } - else if (md5_check == false) { - this.gui.error(String.format("Verification of downloaded %s has failed. Retrying now", download_type)); - this.log.debug("Client::downloadFile problem with Client::checkFile mismatch on md5, removing local file (path: " + local_path + ")"); - } - local_path_file.delete(); - - this.log.debug("Client::downloadFile failed, let's try again (" + (attempts + 1) + "/" + this.maxDownloadFileAttempts + ") ..."); - - ret = this.server.HTTPGetFile(url, local_path, this.gui, update_ui); - - md5_check = this.checkFile(ajob, local_path, md5_server); - attempts++; - - if ((ret != Error.Type.OK || md5_check == false) && attempts >= this.maxDownloadFileAttempts) { - this.log.debug("Client::downloadFile failed after " + this.maxDownloadFileAttempts + " attempts, removing local file (path: " + local_path - + "), stopping..."); - local_path_file.delete(); - return Type.DOWNLOAD_FILE; - } - } - - return Type.OK; - } - - private boolean checkFile(Job ajob, String local_path, String md5_server) { - File local_path_file = new File(local_path); - - if (local_path_file.exists() == false) { - this.log.error("Client::checkFile cannot check md5 on a nonexistent file (path: " + local_path + ")"); - return false; - } - - String md5_local = Utils.md5(local_path); - - if (md5_local.equals(md5_server) == false) { - this.log.error( - "Client::checkFile mismatch on md5 local: '" + md5_local + "' server: '" + md5_server + "' (local size: " + new File(local_path).length() - + ")"); - return false; - } - - return true; + return (new DownloadManager( + this.server, + this.gui, + this.log, + "renderer", + ajob.getRequiredRendererArchivePath(), + ajob.getRendererMD5(), + String.format(LOCALE, "%s?job=%s", this.server.getPage("download-binary"), ajob.getId()) + )).download(); } protected void removeSceneDirectory(Job ajob) { diff --git a/src/main/java/com/sheepit/client/DownloadManager.java b/src/main/java/com/sheepit/client/DownloadManager.java new file mode 100644 index 0000000..8d1c4b2 --- /dev/null +++ b/src/main/java/com/sheepit/client/DownloadManager.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2023 Laurent CLOUET + * Author Laurent CLOUET + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; version 2 + * of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package com.sheepit.client; + +import com.sheepit.client.exception.SheepItException; + +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +public class DownloadManager { + private static int maxDownloadFileAttempts = 5; + + // global objects + private Server server; + private Gui gui; + private Log log; + + // task specific objects + private String gui_text; // what do display on the gui + private String local_target; + private String md5; // expected md5 of the file, for check purpose + private String remote; // remote url + + public DownloadManager(Server server, Gui gui, Log log, String gui_text, String local_target, String md5, String remote) { + this.server = server; + this.gui = gui; + this.gui_text = gui_text; + this.log = log; + this.local_target = local_target; + this.md5 = md5; + this.remote = remote; + } + + public Error.Type download() throws SheepItException { + File local_path_file = new File(this.local_target); + + int remaining = 1800000; // 30 minutes max timeout + + try { + // For a maximum of 30 minutes + do { + // if the binary or scene already exists in the cache + if (local_path_file.exists()) { + this.gui.status("Reusing cached " + this.gui_text); + return Error.Type.OK; + } + // if the binary or scene is being downloaded by another client + else if (this.lockExists()) { + // Wait and check every second for file download completion but only update the GUI every 10 seconds to minimise CPU load + if (remaining % 10000 == 0) { + this.gui.status(String.format("Another client is downloading the %s. Cancel in %dmin %ds", + this.gui_text, + TimeUnit.MILLISECONDS.toMinutes(remaining), + TimeUnit.MILLISECONDS.toSeconds(remaining) - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(remaining)) + )); + } + } + else { + // The file doesn't yet exist not is being downloaded by another client, so immediately create the file with zero bytes to allow early + // detection by other concurrent clients and start downloading process + this.createLock(); + + break; + } + + // wait about 1 second on average + int wait = 1 + (new Random()).nextInt(2000); + Thread.sleep(wait); + remaining -= wait; + } while (remaining > 0); + } + catch (InterruptedException e) { + log.debug("Error in the thread wait. Exception " + e.getMessage()); + } + finally { + // If we have reached the timeout (30 minutes trying to download the client) delete the partial downloaded copy and try to download again + if (remaining <= 0) { + log.debug("ERROR while waiting for download to finish in another client. Deleting the partial file and downloading a fresh copy now!."); + this.removeLock(); + } + } + + this.gui.status(String.format("Downloading %s", this.gui_text)); + + return this.downloadActual(); + } + + private Error.Type downloadActual() throws SheepItException { + String update_ui = "Downloading " + this.gui_text; + + // must download the archive + Error.Type ret = this.server.HTTPGetFile(this.remote, this.local_target, this.gui, update_ui); + + if (ret == Error.Type.RENDERER_KILLED_BY_SERVER || ret == Error.Type.RENDERER_KILLED_BY_USER_OVER_TIME || ret == Error.Type.RENDERER_KILLED_BY_USER) { + return ret; + } + + // Try to check the download file even if a download error has occurred (MD5 file check will delete the file if partially downloaded) + boolean md5_check = this.check(); + int attempts = 1; + + while ((ret != Error.Type.OK || md5_check == false) && attempts < this.maxDownloadFileAttempts) { + if (ret != Error.Type.OK) { + this.gui.error(String.format("Unable to download %s (error %s). Retrying now", this.gui_text, ret)); + this.log.debug("DownloadManager::downloadActual problem with Server.HTTPGetFile (return: " + ret + ") removing local file (path: " + this.local_target + ")"); + } + else if (md5_check == false) { + this.gui.error(String.format("Verification of downloaded %s has failed. Retrying now", this.gui_text)); + this.log.debug("DownloadManager::downloadActual problem with Client::checkFile mismatch on md5, removing local file (path: " + this.local_target + ")"); + } + + (new File(this.local_target)).delete(); + + this.log.debug("DownloadManager::downloadActual failed, let's try again (" + (attempts + 1) + "/" + this.maxDownloadFileAttempts + ") ..."); + + String partial_target = this.local_target + ".partial"; + ret = this.server.HTTPGetFile(this.remote, partial_target, this.gui, update_ui); + + md5_check = this.check(); + attempts++; + + if ((ret != Error.Type.OK || md5_check == false) && attempts >= this.maxDownloadFileAttempts) { + this.log.debug("DownloadManager::downloadActual failed after " + this.maxDownloadFileAttempts + " attempts, removing local file (path: " + this.local_target + "), stopping..."); + // local_path_file.delete(); + return Error.Type.DOWNLOAD_FILE; + } + else { + return (new File (partial_target)).renameTo(new File(this.local_target)) ? Error.Type.OK : Error.Type.DOWNLOAD_FILE; + } + } + + return Error.Type.OK; + } + + private boolean check() { + File local_path_file = new File(this.local_target); + + if (local_path_file.exists() == false) { + this.log.error("DownloadManager::check cannot check md5 on a nonexistent file (path: " + this.local_target + ")"); + return false; + } + + String md5_local = Utils.md5(this.local_target); + + if (md5_local.equals(this.md5) == false) { + this.log.error("DownloadManager::check mismatch on md5 local: '" + md5_local + "' server: '" + this.md5 + "' (local size: " + new File(this.local_target).length() + ")"); + return false; + } + + return true; + } + + private boolean lockExists() { + return new File(this.local_target + ".partial").exists(); + } + + private void createLock() { + try { + File file = new File(this.local_target + ".partial"); + file.createNewFile(); + file.deleteOnExit(); // if the client crashes, the temporary file will be removed + } + catch (IOException e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + this.log.error("DownloadManager::createLock Unable to create .partial temp file for binary/scene " + this.local_target); + this.log.error("DownloadManager::createLock Exception " + e + " stacktrace " + sw.toString()); + } + } + + private void removeLock() { + new File(this.local_target + ".partial").delete(); + } +}