diff --git a/dcc_fetch.py b/dcc_fetch.py index c84d62a..7a0e383 100644 --- a/dcc_fetch.py +++ b/dcc_fetch.py @@ -274,6 +274,136 @@ def new_fetch_row(bot, filename, now=None, request_type="file"): _seen_absent = set() _back_since = {} +# PAUSED BOTS (#926 item 4). A bot we could not connect to this many times in a +# row is paused - its requests wait, "Paused", until the operator resumes it - +# the way AutoGet disabled a nick after three "unable to connect" failures: a +# bot behind a firewall that cannot accept our connection fails every file the +# same way, and asking on burns its slot and ours. The operator can pause and +# resume any bot too. Kept in a small file beside the fetch history, so a pause +# survives a restart; the consecutive-failure count does not need to. +CONNECT_FAILURES_TO_PAUSE = 3 +_paused = {} # bot (lowercased) -> {"nick", "reason", "since", "by"} +_connect_failures = {} # bot (lowercased) -> consecutive active-connect failures + +# A FULL DISK (#926 item 4). No new fetch starts while FETCHED_FILES_DIR has +# less than this free; a transfer that runs out of space mid-way goes back to +# pending rather than failing, and everything resumes by itself once space is +# freed. AutoGet switched itself off when a write failed; waiting is kinder. +MIN_FREE_BYTES = 200 * 1024 * 1024 +_disk_was_low = [False] + + +def _paused_path(): + """Beside the fetch history - so wherever that is redirected (tests, + FETCH_HISTORY_FILE), this goes with it.""" + return os.path.join(os.path.dirname(os.path.abspath(db.FETCH_HISTORY_FILE)), + "fetch_paused_bots.json") + + +def load_paused_bots(): + """Read the paused bots back at startup. A missing or unreadable file is + no pauses, not a refusal to start.""" + import json + _paused.clear() + try: + with open(_paused_path(), "r", encoding="utf-8") as handle: + loaded = json.load(handle) + except (OSError, ValueError): + return + if isinstance(loaded, dict): + _paused.update({str(key).lower(): value for key, value in loaded.items() + if isinstance(value, dict)}) + + +def _save_paused_bots(): + import json + try: + with db._disk_lock: + db._atomic_write(_paused_path(), json.dumps(_paused, indent=1, sort_keys=True)) + except Exception as err: + print(f"[FETCH] Could not save the paused bots: {err}") + + +def paused_bots(): + """{bot (lowercased): {"nick", "reason", "since", "by"}}, a copy.""" + return {key: dict(value) for key, value in _paused.items()} + + +def pause_bot(bot, reason, by="operator"): + nick = str(bot or "").strip() + if not nick: + return False + _paused[nick.lower()] = {"nick": nick, "reason": str(reason), "since": time.time(), "by": by} + _connect_failures.pop(nick.lower(), None) + _save_paused_bots() + print(f"[FETCH] Paused fetching from {nick}: {reason}") + return True + + +def resume_bot(bot): + key = str(bot or "").strip().lower() + if _paused.pop(key, None) is None: + return False + _connect_failures.pop(key, None) + _save_paused_bots() + print(f"[FETCH] Resumed fetching from {bot}.") + return True + + +def _note_connect_failure(bot): + """One more time we could not connect to this bot; the third in a row + pauses it, and says so where the operator looks.""" + key = str(bot or "").strip().lower() + if not key or key in _paused: + return + _connect_failures[key] = _connect_failures.get(key, 0) + 1 + if _connect_failures[key] >= CONNECT_FAILURES_TO_PAUSE: + pause_bot(bot, f"could not connect {CONNECT_FAILURES_TO_PAUSE} times in a row", by="auto") + try: + import announce + announce.send_debug(f"Fetching from {bot} is paused: could not connect " + f"{CONNECT_FAILURES_TO_PAUSE} times in a row. Resume it on the " + f"Downloads page when it can take connections.", category="INFO") + except Exception: + pass + + +def _note_connect_success(bot): + _connect_failures.pop(str(bot or "").strip().lower(), None) + + +def _disk_is_low(): + """Whether FETCHED_FILES_DIR has less than MIN_FREE_BYTES free. Said once + when it becomes low and once when it recovers. A disk that cannot be + measured is not called low - the write itself still fails safely.""" + import shutil + folder = getattr(config, "FETCHED_FILES_DIR", "") or "." + try: + free = shutil.disk_usage(platform_compat.long_path(os.path.abspath(folder))).free + except OSError: + return False + low = free < MIN_FREE_BYTES + if low != _disk_was_low[0]: + _disk_was_low[0] = low + message = (f"Fetching is waiting: under {MIN_FREE_BYTES // (1024 * 1024)} MB free where fetched " + f"files go. It carries on by itself once space is freed." + if low else "Fetching carries on: there is space for fetched files again.") + print(f"[FETCH] {message}") + try: + import announce + announce.send_debug(message, category="INFO") + except Exception: + pass + return low + + +def _is_disk_full(err): + """Whether an error is the disk running out of space (ENOSPC; Windows + reports ERROR_DISK_FULL as the same errno).""" + import errno + return isinstance(err, OSError) and (err.errno == errno.ENOSPC + or getattr(err, "winerror", None) in (112, 39)) + # MAX_UNRESOLVED_FETCHES: the ceiling on how many rows may sit unresolved # (pending or in flight) at once, across every requester. # @@ -859,6 +989,10 @@ def check_fetch_queue(): waiting_bots = {str(row.get("bot", "")).strip().lower() for row in queue.values() if row.get("state") == "pending"} readiness = _bot_readiness(waiting_bots, now) + for bot in waiting_bots: + if bot in _paused: + readiness[bot] = "paused" + disk_low = bool(waiting_bots) and _disk_is_low() to_dispatch = [] with _fetch_lock(): @@ -934,6 +1068,8 @@ def check_fetch_queue(): row = queue[rid] key = str(row.get("bot", "")).strip().lower() why = readiness.get(key, "") + if not why and disk_low: + why = "disk-full" if not why and (row.get("retry_at") or 0) > now: why = "retry" if not why and max_per_bot > 0 and load.get(key, 0) >= max_per_bot: @@ -1932,6 +2068,10 @@ def _run_transfer(row, offer, dest_dir, stored_name, sock=None): except Exception as connect_err: _mark_failed_locked(row, f"connect error: {connect_err}") print(f"[FETCH] Could not connect to {offer['ip']}:{offer['port']}: {connect_err}") + # Only an ACTIVE connect counts (#926): we dialled them and could + # not reach them. A passive offer that nobody connects back to is + # about our side, not theirs. + _note_connect_failure(row.get("bot")) try: sock.close() except Exception: @@ -1952,6 +2092,7 @@ def _run_transfer(row, offer, dest_dir, stored_name, sock=None): bytes_received = 0 failure_reason = None + disk_full = False handle = None try: # Two different limits, and long_path() only lifts one of them. The @@ -1990,6 +2131,7 @@ def _run_transfer(row, offer, dest_dir, stored_name, sock=None): row["bytes_received"] = bytes_received except Exception as recv_err: failure_reason = f"transfer error: {recv_err}" + disk_full = _is_disk_full(recv_err) finally: try: if handle: @@ -2004,6 +2146,7 @@ def _run_transfer(row, offer, dest_dir, stored_name, sock=None): if failure_reason is None and bytes_received == total_size: row["state"] = "complete" row["bytes_received"] = bytes_received + _note_connect_success(row.get("bot")) if row.get("request_type") == "list": # The DCC transfer itself succeeded (declared size matched what # arrived) - that is what "complete" above means, and is left @@ -2033,8 +2176,17 @@ def _run_transfer(row, offer, dest_dir, stored_name, sock=None): if failure_reason is None: failure_reason = f"incomplete transfer ({bytes_received}/{total_size} bytes)" - _mark_failed_locked(row, failure_reason) - print(f"[FETCH] Failed ({failure_reason}): {stored_name}.") + if disk_full: + # Not this request's fault (#926): it goes back to pending, and the + # dispatcher holds everything until there is space again. + row.update(state="pending", offered_at=None, bytes_received=0, + reason="the disk filled up - asking again once there is space", + waiting="disk-full") + _disk_was_low[0] = False # so the next check says it + print(f"[FETCH] The disk filled up receiving {stored_name}; it will be asked again.") + else: + _mark_failed_locked(row, failure_reason) + print(f"[FETCH] Failed ({failure_reason}): {stored_name}.") try: if os.path.exists(platform_compat.long_path(dest_path)): # Unwrapped, exists() answers False for a >260 path and the diff --git a/docs/UPDATES-PUBLIC.md b/docs/UPDATES-PUBLIC.md index 81ae8c0..44bd52d 100644 --- a/docs/UPDATES-PUBLIC.md +++ b/docs/UPDATES-PUBLIC.md @@ -2,6 +2,7 @@ ## Unreleased +- **Added: a bot that keeps failing is paused, and a full disk makes downloads wait.** If DCCore cannot connect to a bot three times in a row, that bot is paused - its downloads stay queued with a **Resume** button on the Downloads page - instead of failing file after file. And when the drive fetched files go to has less than 200 MB free, downloads wait instead of failing, and carry on by themselves once there is space. - **Added: the Downloads queue looks after itself.** Files you queue from another bot wait for it if it is offline and are asked for a minute after it comes back; only three are asked of one bot at a time (*Files asked of one bot at once*), the next going when one arrives, so a big selection no longer earns "queue full"; a "busy" answer is asked again later, three times; and unfinished downloads survive a restart. The Downloads page says why each one is waiting. - **Changed: searching and downloading keep working while the list rebuilds.** They used to be refused for the whole of `!update` (or a scheduled rebuild) - often a minute or more. The new list is built next to the current one, so the bot now keeps answering from the list people already have and pauses only for the few seconds it takes to swap the new one in; a request that lands in those seconds is asked to try again shortly. *Pause for the whole rebuild* on the Settings page (`PAUSE_FOR_WHOLE_UPDATE`) brings back the old behaviour. - **Fixed: fetching from a busy bot now works.** When another bot answered "you're number 12 in my queue", DCCore ignored it, gave up after a minute - and then refused the file when it arrived later. It now understands what file servers answer (OmeNServE, SDFind, SpR, BWI and DCCore itself): a queued request shows its place in their queue on the Downloads page and waits for its turn (up to 12 hours, *Wait for a queued request*), and "I don't have that file" or "queue full" ends the request straight away with the server's own words instead of a minute of silence. diff --git a/docs/UPDATES.md b/docs/UPDATES.md index 5ca88a7..08fdf68 100644 --- a/docs/UPDATES.md +++ b/docs/UPDATES.md @@ -4,6 +4,34 @@ All version changes, optimizations, and bug fixes made over time in the DCCore p ## 🟨 Unreleased +### ⏸️ A bot we cannot reach is paused, and a full disk makes fetching wait (#926) + +Item 4 of #926, stacked on the queue pacing below. + +- **Three failed connections pause a bot.** A bot behind a firewall that cannot take our connection fails every + file the same way, and asking on burns its slot and ours; AutoGet disabled a nick after three "unable to connect". + Now three *active* connect failures in a row (`CONNECT_FAILURES_TO_PAUSE`) pause the bot: its requests stay + queued and wait, *Paused - resume it to carry on*, with a **Resume this bot** button on the Downloads page, and + the debug feed says so once. A finished transfer resets the count. A passive offer nobody connects back to is not + counted - that one is our side, not theirs. +- **Pause and resume any bot** (`POST /api/fetch/pause` and `/api/fetch/resume`, `GET /api/fetch/paused`). Pauses + are saved to `fetch_paused_bots.json` beside the fetch history, so one survives a restart; the failure count does + not need to. +- **A full disk makes fetching wait instead of fail.** With under `MIN_FREE_BYTES` (200 MB) free where fetched files + go, no new fetch starts - the rows wait, *Waiting for disk space* - and a transfer that runs out of space goes back + to pending rather than failing. Both carry on by themselves once space is freed, and the change is said once each + way. A disk that cannot be measured is not called low; the write itself still fails safely. AutoGet switched + itself off on a write error; waiting is kinder. + +`tests/test_a_failing_bot_is_paused.py` (14): the third failure pausing and saying so, a finished transfer - through +the real `_run_transfer()` over a socket pair - resetting the count, only the active connect counting; a paused +bot's requests waiting while others go, resuming, the operator pausing any bot, resuming one not paused, the pause +surviving a restart (and the resume being saved); a low disk holding fetches and saying so once, carrying on once +there is space, an unmeasurable disk not blocking, a transfer that fills the disk - the real `_run_transfer()` into +a file that says no space left - going back to pending, and recognising the error. Mutation-checked: never pausing, +the real transfer not resetting the count, the pause ignored, the disk ignored, a full disk failing the row, and a +resume not saved each fail a test. + ### 🗂️ The fetch queue waits for a bot, paces itself per bot, and survives a restart (#926) Items 2 and 3 of #926, stacked on the reply handling below. The fetch queue could only ask a bot that was in a diff --git a/oserve.py b/oserve.py index b6fdcbd..1a941fe 100644 --- a/oserve.py +++ b/oserve.py @@ -321,6 +321,12 @@ def startup(setup_page=None): # in-memory only until now, so a completed download and its Delete # button both silently vanished from the dashboard on every restart. config.fetch_queue.update(db.load_fetch_history()) + # And the bots whose fetching is paused (#926), kept beside it. + try: + import dcc_fetch + dcc_fetch.load_paused_bots() + except Exception as paused_err: + print(f"[FETCH] Could not read the paused bots: {paused_err}") # The notices survive a restart, which is the whole point of them: an # event worth a badge is by definition one that happened while nobody was # looking, and a kick at three in the morning that is gone by nine is a diff --git a/tests/support.py b/tests/support.py index 4b115d0..b2cfd08 100644 --- a/tests/support.py +++ b/tests/support.py @@ -256,6 +256,9 @@ def reset_config(**overrides): if fetch_module is not None: fetch_module._seen_absent.clear() fetch_module._back_since.clear() + fetch_module._paused.clear() + fetch_module._connect_failures.clear() + fetch_module._disk_was_low[0] = False # What the version check (#572) last found. Read from runtime.py itself, # not through config, so reset there: a release "found" by one test would diff --git a/tests/test_a_failing_bot_is_paused.py b/tests/test_a_failing_bot_is_paused.py new file mode 100644 index 0000000..6bb73fb --- /dev/null +++ b/tests/test_a_failing_bot_is_paused.py @@ -0,0 +1,231 @@ +"""#926 item 4: a bot we cannot reach is paused, any bot can be paused and +resumed, and a full disk holds fetching back instead of failing it. + +- Three ACTIVE connect failures in a row pause a bot (AutoGet disabled a nick + after three "unable to connect"): it fails every file the same way. A + finished transfer resets the count; a passive offer nobody connects back to + is our side, not theirs, and does not count. +- A paused bot's requests wait, "Paused", with a Resume button; the pause + survives a restart. +- Under MIN_FREE_BYTES free where fetched files go, no new fetch starts; a + transfer that fills the disk goes back to pending; both carry on by + themselves once there is space. +""" + +import errno +import io +import os +import shutil +import sys +import time +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if REPO_ROOT not in sys.path: + sys.path.insert(0, REPO_ROOT) + +import announce # noqa: E402 +import dcc_fetch # noqa: E402 +import defaults as config # noqa: E402 +import webserver # noqa: E402 + +from tests.support import DCCoreTestCase, silence_debug # noqa: E402 + + +class TcpLike: + """One end of socket.socketpair(), answering getpeername() the way a real + fetch's TCP socket does. On Linux and macOS the pair is AF_UNIX, whose + getpeername() is '' - and _run_transfer() reads [0] of it, which only a + TCP address has. On Windows the pair is TCP already.""" + + def __init__(self, sock): + self._sock = sock + + def getpeername(self): + return ("127.0.0.1", 50000) + + def __getattr__(self, name): + return getattr(self._sock, name) + + +class PauseCase(DCCoreTestCase): + def setUp(self): + super().setUp() + self.set_config(fetch_queue={}, MAX_FETCH_SLOTS=10, fetch_feature_disabled=False, + CHANNEL="#chan", FETCH_MAX_PER_BOT=0) + config.channel_users["#chan"] = {"serverone", "servertwo"} + self.feed = silence_debug(announce) + + def queue_up(self, bot="ServerOne", count=1): + return [dcc_fetch.enqueue_fetch(bot, f"Track {n}.flac") for n in range(count)] + + def state(self, rid): + return config.fetch_queue[rid]["state"] + + +class ThreeFailuresPauseABot(PauseCase): + def test_the_third_in_a_row_pauses_it_and_says_so(self): + for _ in range(2): + dcc_fetch._note_connect_failure("ServerOne") + self.assertNotIn("serverone", dcc_fetch.paused_bots()) + dcc_fetch._note_connect_failure("ServerOne") + paused = dcc_fetch.paused_bots()["serverone"] + self.assertEqual(paused["by"], "auto") + self.assertIn("could not connect 3 times", paused["reason"]) + self.assertTrue(any("Fetching from ServerOne is paused" in text for _c, text in self.feed)) + + def test_a_finished_transfer_resets_the_count(self): + dcc_fetch._note_connect_failure("ServerOne") + dcc_fetch._note_connect_failure("ServerOne") + dcc_fetch._note_connect_success("ServerOne") + dcc_fetch._note_connect_failure("ServerOne") + self.assertNotIn("serverone", dcc_fetch.paused_bots()) + + def test_a_real_finished_transfer_resets_it(self): + """Through _run_transfer() itself, over a local socket pair.""" + import socket + import tempfile + dcc_fetch._note_connect_failure("ServerOne") + dcc_fetch._note_connect_failure("ServerOne") + ours, theirs = socket.socketpair() + self.addCleanup(ours.close) + self.addCleanup(theirs.close) + theirs.sendall(b"x" * 64) + (rid,) = self.queue_up() + row = config.fetch_queue[rid] + row.update(state="receiving") + dest = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, dest, ignore_errors=True) + dcc_fetch._run_transfer(row, {"size": 64, "ip": None, "port": 0}, dest, "Track.flac", sock=TcpLike(ours)) + self.assertEqual(row["state"], "complete") + dcc_fetch._note_connect_failure("ServerOne") + self.assertNotIn("serverone", dcc_fetch.paused_bots()) + + def test_only_an_active_connect_counts(self): + """The call sits in the active connect's failure branch, not the + passive listener's.""" + with io.open(os.path.join(REPO_ROOT, "dcc_fetch.py"), encoding="utf-8") as handle: + code = handle.read() + connect = code.index('_mark_failed_locked(row, f"connect error: {connect_err}")') + self.assertIn("_note_connect_failure(row.get(\"bot\"))", code[connect:connect + 600]) + passive = code.index('_mark_failed_locked(row, "passive offer: no connection received")') + self.assertNotIn("_note_connect_failure", code[passive - 400:passive + 400]) + + +class APausedBotWaits(PauseCase): + def test_its_requests_wait_as_paused_and_others_go(self): + dcc_fetch.pause_bot("ServerOne", "testing") + (one,) = self.queue_up("ServerOne") + (two,) = self.queue_up("ServerTwo") + dcc_fetch.check_fetch_queue() + self.assertEqual(self.state(one), "pending") + self.assertEqual(config.fetch_queue[one]["waiting"], "paused") + self.assertEqual(self.state(two), "offered") + + def test_resumed_it_goes(self): + dcc_fetch.pause_bot("ServerOne", "testing") + (one,) = self.queue_up("ServerOne") + dcc_fetch.check_fetch_queue() + status, _result = webserver.build_fetch_pause_result({"bot": "serverone"}, False) + self.assertEqual(status, 200) + dcc_fetch.check_fetch_queue() + self.assertEqual(self.state(one), "offered") + + def test_the_operator_can_pause_any_bot(self): + status, _result = webserver.build_fetch_pause_result({"bot": "ServerTwo"}, True) + self.assertEqual(status, 200) + self.assertEqual(dcc_fetch.paused_bots()["servertwo"]["by"], "operator") + + def test_resuming_a_bot_that_is_not_paused_says_so(self): + status, _result = webserver.build_fetch_pause_result({"bot": "ServerTwo"}, False) + self.assertEqual(status, 404) + self.assertEqual(webserver.build_fetch_pause_result({}, True)[0], 400) + + def test_a_pause_survives_a_restart(self): + dcc_fetch.pause_bot("ServerOne", "testing") + dcc_fetch._paused.clear() + dcc_fetch.load_paused_bots() + self.assertIn("serverone", dcc_fetch.paused_bots()) + dcc_fetch.resume_bot("ServerOne") + dcc_fetch._paused["serverone"] = {"nick": "x"} + dcc_fetch.load_paused_bots() + self.assertNotIn("serverone", dcc_fetch.paused_bots(), "the resume was saved too") + + +class AFullDiskWaits(PauseCase): + def low_disk(self, free): + real = shutil.disk_usage + + def usage(path): + return shutil._ntuple_diskusage(10 ** 12, 10 ** 12 - free, free) + shutil.disk_usage = usage + self.addCleanup(setattr, shutil, "disk_usage", real) + + def test_no_new_fetch_starts_and_it_is_said_once(self): + self.low_disk(10 * 1024 * 1024) + (rid,) = self.queue_up() + dcc_fetch.check_fetch_queue() + dcc_fetch.check_fetch_queue() + self.assertEqual(self.state(rid), "pending") + self.assertEqual(config.fetch_queue[rid]["waiting"], "disk-full") + said = [text for _c, text in self.feed if "Fetching is waiting" in text] + self.assertEqual(len(said), 1) + + def test_it_carries_on_by_itself_once_there_is_space(self): + self.low_disk(10 * 1024 * 1024) + (rid,) = self.queue_up() + dcc_fetch.check_fetch_queue() + self.low_disk(10 * 1024 ** 3) + dcc_fetch.check_fetch_queue() + self.assertEqual(self.state(rid), "offered") + self.assertTrue(any("there is space for fetched files again" in text for _c, text in self.feed)) + + def test_a_disk_that_cannot_be_measured_is_not_called_low(self): + def broken(path): + raise OSError("no such device") + real = shutil.disk_usage + shutil.disk_usage = broken + self.addCleanup(setattr, shutil, "disk_usage", real) + (rid,) = self.queue_up() + dcc_fetch.check_fetch_queue() + self.assertEqual(self.state(rid), "offered") + + def test_a_transfer_that_fills_the_disk_goes_back_to_pending(self): + """The real _run_transfer(), over a local socket pair, into a file + whose write says there is no space left.""" + import socket + import tempfile + + class Full: + def write(self, data): + raise OSError(errno.ENOSPC, "No space left on device") + + def close(self): + pass + + dcc_fetch.open = lambda *args, **kwargs: Full() + self.addCleanup(delattr, dcc_fetch, "open") + ours, theirs = socket.socketpair() + self.addCleanup(ours.close) + self.addCleanup(theirs.close) + theirs.sendall(b"x" * 64) + (rid,) = self.queue_up() + row = config.fetch_queue[rid] + row.update(state="receiving") + dest = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, dest, ignore_errors=True) + + dcc_fetch._run_transfer(row, {"size": 64, "ip": None, "port": 0}, dest, "Track.flac", sock=TcpLike(ours)) + + self.assertEqual(row["state"], "pending") + self.assertEqual(row["waiting"], "disk-full") + self.assertEqual(row["bytes_received"], 0) + + def test_disk_full_is_recognised(self): + self.assertTrue(dcc_fetch._is_disk_full(OSError(errno.ENOSPC, "No space left on device"))) + self.assertFalse(dcc_fetch._is_disk_full(OSError(errno.ECONNRESET, "reset"))) + self.assertFalse(dcc_fetch._is_disk_full(ValueError("x"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.js b/web/app.js index 1447603..d1ace37 100644 --- a/web/app.js +++ b/web/app.js @@ -768,7 +768,8 @@ var DOWNLOAD_WAITING_LABELS = { offline: "download.waiting.offline", "just-back": "download.waiting.justBack", retry: "download.waiting.retry", "their-turn": "download.waiting.theirTurn", - slots: "download.waiting.slots" + slots: "download.waiting.slots", paused: "download.waiting.paused", + "disk-full": "download.waiting.diskFull" }; function loadDownloads() { @@ -784,6 +785,20 @@ }).catch(function () { markConnection(false); }); } + // #926: resume a paused bot from one of its waiting rows. The bot comes + // from the held row, looked up by id - never from an attribute. + function resumeFetchBot(button) { + var requestId = decodeURIComponent(button.dataset.requestId); + var row = (state.downloads || []).filter(function (candidate) { + return String(candidate.id) === requestId; + })[0]; + if (!row) { return; } + button.disabled = true; + postJson("/api/fetch/resume", { bot: row.bot }).then(function () { + loadDownloads(); + }).catch(function () { button.disabled = false; }); + } + function redownloadFetchRow(button) { var requestId = decodeURIComponent(button.dataset.requestId); var row = (state.downloads || []).filter(function (candidate) { @@ -835,6 +850,11 @@ redownloadFetchRow(retry); return; } + var resume = evt.target.closest ? evt.target.closest(".fetch-resume-btn") : null; + if (resume) { + resumeFetchBot(resume); + return; + } var btn = evt.target.closest ? evt.target.closest(".fetch-delete-btn") : null; if (!btn) { return; } @@ -933,6 +953,10 @@ action = "" + t("download.browseInListBrowser") + " " + deleteBtn; } else if (state === "failed") { action = "" + escapeHtml(row.reason || "") + " " + retryBtn + deleteBtn; + } else if (state === "pending" && row.waiting === "paused") { + // #926: a paused bot's requests wait here; one click resumes it. + action = " " + deleteBtn; } else if (state === "pending") { action = deleteBtn; } else if (state === "queued") { diff --git a/web/lang/en.json b/web/lang/en.json index c5b3e82..520d530 100644 --- a/web/lang/en.json +++ b/web/lang/en.json @@ -89,6 +89,9 @@ "download.waiting.retry": "Busy - asking again later", "download.waiting.theirTurn": "Waiting - {bot} has enough of ours", "download.waiting.slots": "Waiting for a free slot", + "download.waiting.paused": "Paused - resume it to carry on", + "download.waiting.diskFull": "Waiting for disk space", + "download.resumeBot": "Resume this bot", "download.state.listening": "Listening", "download.state.receiving": "Receiving", "download.state.complete": "Complete", diff --git a/web/lang/es.json b/web/lang/es.json index 4f595f1..085c60c 100644 --- a/web/lang/es.json +++ b/web/lang/es.json @@ -89,6 +89,9 @@ "download.waiting.retry": "Ocupado - se volverá a pedir más tarde", "download.waiting.theirTurn": "Esperando - {bot} ya tiene bastantes nuestros", "download.waiting.slots": "Esperando un hueco libre", + "download.waiting.paused": "En pausa - reanúdelo para continuar", + "download.waiting.diskFull": "Esperando espacio en disco", + "download.resumeBot": "Reanudar este bot", "download.state.listening": "Escuchando", "download.state.receiving": "Recibiendo", "download.state.complete": "Completo", diff --git a/web/lang/fr.json b/web/lang/fr.json index 75948fa..1a78cff 100644 --- a/web/lang/fr.json +++ b/web/lang/fr.json @@ -89,6 +89,9 @@ "download.waiting.retry": "Occupé - nouvelle demande plus tard", "download.waiting.theirTurn": "En attente - {bot} en a assez des nôtres", "download.waiting.slots": "En attente d'un créneau libre", + "download.waiting.paused": "En pause - reprenez-le pour continuer", + "download.waiting.diskFull": "En attente d'espace disque", + "download.resumeBot": "Reprendre ce bot", "download.state.listening": "Écoute", "download.state.receiving": "Réception", "download.state.complete": "Terminé", diff --git a/webserver.py b/webserver.py index 099864b..16089a9 100644 --- a/webserver.py +++ b/webserver.py @@ -2040,6 +2040,25 @@ def build_fetch_status_payload(): return rows +def build_fetch_pause_result(payload, pause): + """POST /api/fetch/pause and /api/fetch/resume (#926): stop or restart + fetching from one bot. Its requests stay in the queue - paused ones wait, + "Paused", and go out again once it is resumed.""" + import dcc_fetch + bot = str((payload or {}).get("bot") or "").strip() if isinstance(payload, dict) else "" + if not bot: + return 400, {"error": "Which bot?"} + unsafe = reject_if_unsafe_for_irc_line(bot, "bot") + if unsafe: + return 400, {"error": unsafe} + if pause: + dcc_fetch.pause_bot(bot, "paused from the Downloads page") + return 200, {"paused": bot} + if not dcc_fetch.resume_bot(bot): + return 404, {"error": f"{bot} is not paused."} + return 200, {"resumed": bot} + + def build_fetch_delete_result(request_id): """DELETE /api/fetch/: forget a finished fetch and remove its file from FETCHED_FILES_DIR, if it has one. @@ -4119,6 +4138,21 @@ def api_fetch_enqueue(): def api_fetch_status(): return jsonify(build_fetch_status_payload()) + @app.route("/api/fetch/paused") + def api_fetch_paused(): + import dcc_fetch + return jsonify(dcc_fetch.paused_bots()) + + @app.route("/api/fetch/pause", methods=["POST"]) + def api_fetch_pause(): + status, result = build_fetch_pause_result(request.get_json(silent=True), True) + return jsonify(result), status + + @app.route("/api/fetch/resume", methods=["POST"]) + def api_fetch_resume(): + status, result = build_fetch_pause_result(request.get_json(silent=True), False) + return jsonify(result), status + @app.route("/api/tools/verify-list") def api_tools_verify_list(): return jsonify(build_verify_list_payload())