diff --git a/worker/src/archive/split.ts b/worker/src/archive/split.ts index 17d6401..f427da9 100644 --- a/worker/src/archive/split.ts +++ b/worker/src/archive/split.ts @@ -6,8 +6,12 @@ import { childLogger } from "../util/logger.js"; const log = childLogger("split"); -/** 2GB in bytes — Telegram's file size limit */ -const MAX_PART_SIZE = 2n * 1024n * 1024n * 1024n; +/** + * 1950 MiB — safely under Telegram's 2GB upload limit. + * At exactly 2GiB, TDLib's internal 512KB chunking can exceed Telegram's + * 4000-part threshold, causing FILE_PARTS_INVALID errors. + */ +const MAX_PART_SIZE = 1950n * 1024n * 1024n; /** * Split a file into ≤2GB parts using byte-level splitting. diff --git a/worker/src/tdlib/download.ts b/worker/src/tdlib/download.ts index 336a83b..e96e07d 100644 --- a/worker/src/tdlib/download.ts +++ b/worker/src/tdlib/download.ts @@ -353,11 +353,14 @@ export async function downloadFile( return new Promise((resolve, reject) => { let lastLoggedPercent = 0; let settled = false; + let downloadStarted = false; // True once TDLib reports is_downloading_active + let lastProgressBytes = 0; + let lastProgressTime = Date.now(); - // Timeout: 15 minutes per GB, minimum 10 minutes + // Timeout: 20 minutes per GB, minimum 15 minutes const timeoutMs = Math.max( - 10 * 60_000, - (totalBytes / (1024 * 1024 * 1024)) * 15 * 60_000 + 15 * 60_000, + (totalBytes / (1024 * 1024 * 1024)) * 20 * 60_000 ); const timer = setTimeout(() => { if (!settled) { @@ -371,6 +374,23 @@ export async function downloadFile( } }, timeoutMs); + // Stall detection: no progress for 5 minutes after download started → reject + const STALL_TIMEOUT_MS = 5 * 60_000; + const stallChecker = setInterval(() => { + if (settled || !downloadStarted) return; + const stallMs = Date.now() - lastProgressTime; + if (stallMs >= STALL_TIMEOUT_MS) { + settled = true; + cleanup(); + reject( + new Error( + `Download stalled for ${fileName} — no progress for ${Math.round(stallMs / 60_000)}min ` + + `(${lastProgressBytes}/${totalBytes} bytes)` + ) + ); + } + }, 30_000); + // Listen for file update events to track progress // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleUpdate = (update: any) => { @@ -382,6 +402,17 @@ export async function downloadFile( const percent = totalBytes > 0 ? Math.round((downloaded / totalBytes) * 100) : 0; + // Track whether the download has actually started + if (file.local.is_downloading_active) { + downloadStarted = true; + } + + // Reset stall timer when bytes advance + if (downloaded > lastProgressBytes) { + lastProgressBytes = downloaded; + lastProgressTime = Date.now(); + } + // Log at every 10% increment if (percent >= lastLoggedPercent + 10) { lastLoggedPercent = percent - (percent % 10); @@ -412,8 +443,11 @@ export async function downloadFile( } } - // Download stopped without completing (network error, cancelled, etc.) + // Download stopped without completing — only if it had actually started. + // TDLib may emit an initial updateFile with is_downloading_active=false + // before the download begins; ignoring that prevents false positives. if ( + downloadStarted && !file.local.is_downloading_active && !file.local.is_downloading_completed ) { @@ -432,6 +466,7 @@ export async function downloadFile( const cleanup = () => { clearTimeout(timer); + clearInterval(stallChecker); client.off("update", handleUpdate); }; diff --git a/worker/src/upload/channel.ts b/worker/src/upload/channel.ts index e019d33..fd6212f 100644 --- a/worker/src/upload/channel.ts +++ b/worker/src/upload/channel.ts @@ -93,11 +93,13 @@ async function sendAndWaitForUpload( let settled = false; let lastLoggedPercent = 0; let tempMsgId: number | null = null; + let uploadStarted = false; + let lastProgressTime = Date.now(); - // Timeout: 15 minutes per GB, minimum 10 minutes + // Timeout: 20 minutes per GB, minimum 15 minutes const timeoutMs = Math.max( - 10 * 60_000, - (fileSizeMB / 1024) * 15 * 60_000 + 15 * 60_000, + (fileSizeMB / 1024) * 20 * 60_000 ); const timer = setTimeout(() => { @@ -112,12 +114,31 @@ async function sendAndWaitForUpload( } }, timeoutMs); + // Stall detection: no progress for 5 minutes after upload started → reject + const STALL_TIMEOUT_MS = 5 * 60_000; + const stallChecker = setInterval(() => { + if (settled || !uploadStarted) return; + const stallMs = Date.now() - lastProgressTime; + if (stallMs >= STALL_TIMEOUT_MS) { + settled = true; + cleanup(); + reject( + new Error( + `Upload stalled for ${fileName} — no progress for ${Math.round(stallMs / 60_000)}min` + ) + ); + } + }, 30_000); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleUpdate = (update: any) => { // Track upload progress via updateFile events if (update?._ === "updateFile") { const file = update.file; if (file?.remote?.is_uploading_active && file.expected_size > 0) { + uploadStarted = true; + lastProgressTime = Date.now(); + const uploaded = file.remote.uploaded_size ?? 0; const total = file.expected_size; const percent = Math.round((uploaded / total) * 100); @@ -165,6 +186,7 @@ async function sendAndWaitForUpload( const cleanup = () => { clearTimeout(timer); + clearInterval(stallChecker); client.off("update", handleUpdate); }; diff --git a/worker/src/worker.ts b/worker/src/worker.ts index f028132..37d134e 100644 --- a/worker/src/worker.ts +++ b/worker/src/worker.ts @@ -999,7 +999,7 @@ async function processOneArchiveSet( (sum, p) => sum + p.fileSize, 0n ); - const MAX_UPLOAD_SIZE = 2n * 1024n * 1024n * 1024n; + const MAX_UPLOAD_SIZE = 1950n * 1024n * 1024n; // Match split.ts MAX_PART_SIZE const hasOversizedPart = archiveSet.parts.some((p) => p.fileSize > MAX_UPLOAD_SIZE); if (hasOversizedPart) {