Skip to content

Commit 64d96ed

Browse files
committed
fix: avoid MIME check for already-completed transfers and refactor handler
on_dccmsg was MIME-checking the first chunk even when the transfer was already marked completed, which could reject re-transfers of small files where the resumed chunk does not contain the file header. Skip the check when completed. Also refactor TransferHandler to keep on_dccmsg and on_dcc_disconnect focused, reducing per-module cyclomatic complexity back under the B gate.
1 parent 9f79910 commit 64d96ed

2 files changed

Lines changed: 148 additions & 88 deletions

File tree

‎dccbot/transfer_handler.py‎

Lines changed: 128 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,79 @@ def __init__(self, bot: IRCBot) -> None:
2626
"""Initialize transfer handler for a specific IRC bot instance."""
2727
self.bot = bot
2828

29+
def _touch_channel_activity(self, transfer: dict) -> None:
30+
"""Bump the activity timestamp for channels associated with this nick."""
31+
nick = transfer["nick"].lower()
32+
if nick in self.bot.bot_channel_map:
33+
now = time.time()
34+
for channel in self.bot.bot_channel_map[nick]:
35+
self.bot.joined_channels[channel] = now
36+
37+
def _update_progress(self, transfer: dict) -> None:
38+
"""Recalculate and log transfer progress/rate if thresholds are met."""
39+
now = time.time()
40+
percent = int(100 * (transfer["bytes_received"] + transfer["offset"]) / transfer["size"])
41+
if transfer["percent"] + 10 > percent and now - transfer["last_progress_update"] < 5:
42+
return
43+
44+
transfer["percent"] = percent
45+
elapsed = now - transfer["start_time"]
46+
transfer_rate_avg = (transfer["bytes_received"] / elapsed) / 1024 if elapsed > 0 else 0
47+
elapsed = now - transfer["last_progress_update"]
48+
transferred = transfer["bytes_received"] - transfer["last_progress_bytes_received"]
49+
transfer_rate = (transferred / elapsed) / 1024 if elapsed > 0 else 0
50+
51+
logger.info(
52+
"[%s] Downloading %s %d%% @ %.2f KB/s / %.2f KB/s",
53+
transfer["nick"],
54+
transfer["filename"],
55+
transfer["percent"],
56+
transfer_rate,
57+
transfer_rate_avg,
58+
)
59+
transfer["last_progress_update"] = now
60+
transfer["last_progress_bytes_received"] = transfer["bytes_received"]
61+
62+
def _check_mime_or_disconnect(self, dcc: AioDCCConnection, transfer: dict, data: bytes) -> bool:
63+
"""Validate the first chunk's MIME type or close the connection."""
64+
if transfer["bytes_received"] != 0 or transfer.get("offset") or not self.bot.allowed_mimetypes:
65+
return True
66+
67+
mime_type = self.bot.mime_checker.from_buffer(data)
68+
if mime_type in self.bot.allowed_mimetypes:
69+
return True
70+
71+
logger.warning("[%s] Reject %s: Invalid MIME type (%s)", transfer["nick"], transfer["filename"], mime_type)
72+
transfer["status"] = "error"
73+
transfer["error"] = f"Invalid MIME type ({mime_type})"
74+
transfer["connected"] = False
75+
dcc.disconnect()
76+
self.bot.current_transfers.pop(dcc, None)
77+
return False
78+
79+
def _write_chunk_or_disconnect(self, dcc: AioDCCConnection, transfer: dict, data: bytes) -> bool:
80+
"""Append the chunk to the file or mark the transfer as failed."""
81+
try:
82+
with open(transfer["file_path"], "ab") as f:
83+
f.write(data)
84+
except Exception as e:
85+
logger.error("Error writing to file %s: %s", transfer["file_path"], e)
86+
transfer["status"] = "error"
87+
transfer["error"] = f"Error writing to file {transfer['file_path']}: {e}"
88+
transfer["connected"] = False
89+
dcc.disconnect()
90+
self.bot.current_transfers.pop(dcc, None)
91+
return False
92+
return True
93+
94+
def _send_ack(self, dcc: AioDCCConnection, transfer: dict) -> None:
95+
"""Send the appropriate 32- or 64-bit acknowledgement."""
96+
ack = transfer["bytes_received"] + transfer["offset"]
97+
if transfer["size"] >= 1024 * 1024 * 1024 * 4:
98+
dcc.send_bytes(struct.pack("!Q", ack))
99+
else:
100+
dcc.send_bytes(struct.pack("!I", ack))
101+
29102
def on_dccmsg(self, connection: AioDCCConnection, event: irc.client_aio.Event) -> None:
30103
"""Handle incoming DCC data chunk."""
31104
dcc = connection
@@ -39,60 +112,64 @@ def on_dccmsg(self, connection: AioDCCConnection, event: irc.client_aio.Event) -
39112
data = event.arguments[0]
40113

41114
if not transfer["completed"]:
42-
now = time.time()
43-
if transfer["nick"].lower() in self.bot.bot_channel_map:
44-
for channel in self.bot.bot_channel_map[transfer["nick"].lower()]:
45-
self.bot.joined_channels[channel] = now
46-
47-
percent = int(100 * (transfer["bytes_received"] + transfer["offset"]) / transfer["size"])
48-
if transfer["percent"] + 10 <= percent or now - transfer["last_progress_update"] >= 5:
49-
transfer["percent"] = percent
50-
elapsed_time = now - transfer["start_time"]
51-
transfer_rate_avg = (transfer["bytes_received"] / elapsed_time) / 1024 if elapsed_time > 0 else 0
52-
elapsed_time = now - transfer["last_progress_update"]
53-
transferred_bytes = transfer["bytes_received"] - transfer["last_progress_bytes_received"]
54-
transfer_rate = (transferred_bytes / elapsed_time) / 1024 if elapsed_time > 0 else 0
55-
56-
logger.info(
57-
"[%s] Downloading %s %d%% @ %.2f KB/s / %.2f KB/s",
58-
transfer["nick"],
59-
transfer["filename"],
60-
transfer["percent"],
61-
transfer_rate,
62-
transfer_rate_avg,
63-
)
64-
transfer["last_progress_update"] = now
65-
transfer["last_progress_bytes_received"] = transfer["bytes_received"]
66-
67-
if transfer["bytes_received"] == 0 and not transfer.get("offset") and self.bot.allowed_mimetypes:
68-
mime_type = self.bot.mime_checker.from_buffer(data)
69-
if mime_type not in self.bot.allowed_mimetypes:
70-
logger.warning("[%s] Reject %s: Invalid MIME type (%s)", transfer["nick"], transfer["filename"], mime_type)
71-
transfer["status"] = "error"
72-
transfer["error"] = f"Invalid MIME type ({mime_type})"
73-
transfer["connected"] = False
74-
dcc.disconnect()
75-
self.bot.current_transfers.pop(dcc, None)
76-
return
115+
self._touch_channel_activity(transfer)
116+
self._update_progress(transfer)
117+
if not self._check_mime_or_disconnect(dcc, transfer, data):
118+
return
119+
if not self._write_chunk_or_disconnect(dcc, transfer, data):
120+
return
77121

122+
transfer["bytes_received"] += len(data)
123+
self._send_ack(dcc, transfer)
124+
125+
def _mark_complete(self, transfer: dict, file_path: str, transfer_rate: float) -> None:
126+
"""Mark a transfer as complete, queue MD5 and rename incomplete file."""
127+
logger.info("[%s] Download %s complete - size: %d bytes, %.2f KB/s", transfer["nick"], transfer["filename"], transfer["size"], transfer_rate)
128+
transfer["completed"] = time.time()
129+
transfer["status"] = "completed"
130+
if transfer.get("md5"):
131+
asyncio.create_task(self.bot._add_md5_check_queue_item(transfer))
132+
133+
incomplete_suffix = get_incomplete_suffix(self.bot.config)
134+
if incomplete_suffix and file_path.endswith(incomplete_suffix):
135+
target = file_path[: -len(incomplete_suffix)]
78136
try:
79-
with open(transfer["file_path"], "ab") as f:
80-
f.write(data)
137+
os.rename(file_path, target)
138+
logger.info("Renamed downloaded file to %s", transfer["filename"])
139+
transfer["file_path"] = target
81140
except Exception as e:
82-
logger.error("Error writing to file %s: %s", transfer["file_path"], e)
83-
transfer["status"] = "error"
84-
transfer["error"] = f"Error writing to file {transfer['file_path']}: {e}"
85-
transfer["connected"] = False
86-
dcc.disconnect()
87-
self.bot.current_transfers.pop(dcc, None)
88-
return
141+
logger.error("Error renaming %s to %s: %s", file_path, target, e)
142+
143+
def _report_size_mismatch(self, transfer: dict, file_size: int) -> None:
144+
"""Record a size mismatch failure for a transfer."""
145+
logger.error("[%s] Download %s failed: size mismatch %d != %d", transfer["nick"], transfer["filename"], file_size, transfer["size"])
146+
if transfer["status"] != "error":
147+
transfer["status"] = "failed"
148+
transfer["error"] = f"size mismatch {file_size} != {transfer['size']}"
149+
150+
def _report_missing_file(self, transfer: dict, file_path: str) -> None:
151+
"""Record a missing output file failure."""
152+
logger.error("[%s] Download failed: %s does not exist", transfer["nick"], file_path)
153+
if transfer["status"] != "error":
154+
transfer["status"] = "error"
155+
transfer["error"] = f"[{transfer['nick']}] Download failed: {file_path} does not exist"
156+
157+
def _finalize_transfer(self, dcc: AioDCCConnection, transfer: dict) -> None:
158+
"""Check the output file and finalize the transfer state."""
159+
file_path = transfer["file_path"]
160+
elapsed = time.time() - transfer["start_time"]
161+
transfer_rate = (transfer["bytes_received"] / elapsed) / 1024 if elapsed > 0 else 0
89162

90-
transfer["bytes_received"] += len(data)
91-
ack = transfer["bytes_received"] + transfer["offset"]
92-
if transfer["size"] >= 1024 * 1024 * 1024 * 4:
93-
dcc.send_bytes(struct.pack("!Q", ack))
163+
if not os.path.exists(file_path):
164+
self._report_missing_file(transfer, file_path)
94165
else:
95-
dcc.send_bytes(struct.pack("!I", ack))
166+
file_size = os.path.getsize(file_path)
167+
if file_size != transfer["size"]:
168+
self._report_size_mismatch(transfer, file_size)
169+
else:
170+
self._mark_complete(transfer, file_path, transfer_rate)
171+
172+
self.bot.current_transfers.pop(dcc, None)
96173

97174
def on_dcc_disconnect(self, connection: AioDCCConnection, event: irc.client_aio.Event) -> None:
98175
"""Handle DCC disconnect and finalize transfer metadata."""
@@ -104,42 +181,5 @@ def on_dcc_disconnect(self, connection: AioDCCConnection, event: irc.client_aio.
104181

105182
transfer = self.bot.current_transfers[dcc]
106183
transfer["connected"] = False
107-
108-
if transfer["nick"].lower() in self.bot.bot_channel_map:
109-
for channel in self.bot.bot_channel_map[transfer["nick"].lower()]:
110-
self.bot.joined_channels[channel] = time.time()
111-
112-
file_path = transfer["file_path"]
113-
elapsed_time = time.time() - transfer["start_time"]
114-
transfer_rate = (transfer["bytes_received"] / elapsed_time) / 1024 if elapsed_time > 0 else 0
115-
116-
if not os.path.exists(file_path):
117-
logger.error("[%s] Download failed: %s does not exist", transfer["nick"], file_path)
118-
if transfer["status"] != "error":
119-
transfer["status"] = "error"
120-
transfer["error"] = f"[{transfer['nick']}] Download failed: {file_path} does not exist"
121-
else:
122-
file_size = os.path.getsize(file_path)
123-
if file_size != transfer["size"]:
124-
logger.error("[%s] Download %s failed: size mismatch %d != %d", transfer["nick"], transfer["filename"], file_size, transfer["size"])
125-
if transfer["status"] != "error":
126-
transfer["status"] = "failed"
127-
transfer["error"] = f"size mismatch {file_size} != {transfer['size']}"
128-
else:
129-
logger.info("[%s] Download %s complete - size: %d bytes, %.2f KB/s", transfer["nick"], transfer["filename"], file_size, transfer_rate)
130-
transfer["completed"] = time.time()
131-
transfer["status"] = "completed"
132-
if transfer.get("md5"):
133-
asyncio.create_task(self.bot._add_md5_check_queue_item(transfer))
134-
135-
incomplete_suffix = get_incomplete_suffix(self.bot.config)
136-
if incomplete_suffix and file_path.endswith(incomplete_suffix):
137-
target = file_path[: -len(incomplete_suffix)]
138-
try:
139-
os.rename(file_path, target)
140-
logger.info("Renamed downloaded file to %s", transfer["filename"])
141-
transfer["file_path"] = target
142-
except Exception as e:
143-
logger.error("Error renaming %s to %s: %s", file_path, target, e)
144-
145-
self.bot.current_transfers.pop(dcc, None)
184+
self._touch_channel_activity(transfer)
185+
self._finalize_transfer(dcc, transfer)

‎tests/unit/test_transfer_handler.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,26 @@ def test_on_dccmsg_write_failure_sets_error():
140140
dcc.disconnect.assert_called_once()
141141

142142

143+
def test_on_dccmsg_skips_mime_check_for_completed_transfer():
144+
"""Test transfer handler does not MIME-check already-completed transfers."""
145+
dcc = MagicMock()
146+
transfer = _make_transfer(size=1024)
147+
transfer["completed"] = True
148+
bot = _make_bot_with_transfer(dcc, transfer)
149+
bot.allowed_mimetypes = ["video/mp4"]
150+
bot.mime_checker.from_buffer.return_value = "text/plain"
151+
handler = TransferHandler(bot)
152+
event = MagicMock()
153+
event.arguments = [b"abc"]
154+
155+
with patch("builtins.open", mock_open()):
156+
handler.on_dccmsg(dcc, event)
157+
158+
bot.mime_checker.from_buffer.assert_not_called()
159+
dcc.disconnect.assert_not_called()
160+
dcc.send_bytes.assert_called_once()
161+
162+
143163
def test_on_dcc_disconnect_unknown_connection():
144164
"""Test unknown disconnect events are ignored safely."""
145165
dcc = MagicMock()

0 commit comments

Comments
 (0)