Skip to content

Commit fcf146f

Browse files
committed
fix: harden model download pipeline and progress UI
Inline redirect handling in downloadUtils (eliminates separate HEAD resolve step), defer write stream creation until response type is known, and add expectedSize fallback for servers omitting Content-Length. Add indeterminate progress bar state for downloads with unknown total size, validate all 4 required Parakeet ONNX files after extraction, and fix progress bar rendering empty container when no speed/eta data.
1 parent fc1336e commit fcf146f

File tree

5 files changed

+117
-85
lines changed

5 files changed

+117
-85
lines changed

src/components/ui/DownloadProgressBar.tsx

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,46 @@ interface DownloadProgressBarProps {
55
isInstalling?: boolean;
66
}
77

8+
function formatBytes(bytes: number): string {
9+
if (bytes < 1_000_000) return `${Math.round(bytes / 1000)}KB`;
10+
if (bytes < 1_000_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`;
11+
return `${(bytes / 1_000_000_000).toFixed(2)}GB`;
12+
}
13+
814
export function DownloadProgressBar({
915
modelName,
1016
progress,
1117
isInstalling,
1218
}: DownloadProgressBarProps) {
13-
const { percentage, speed, eta } = progress;
19+
const { percentage, downloadedBytes, totalBytes, speed, eta } = progress;
1420
const pct = Math.round(percentage);
1521
const speedText = speed ? `${speed.toFixed(1)} MB/s` : "";
1622
const etaText = eta ? formatETA(eta) : "";
23+
const indeterminate = !isInstalling && totalBytes === 0 && downloadedBytes > 0;
1724

1825
return (
1926
<div className="px-2.5 py-2 border-b border-white/5 dark:border-border-subtle">
2027
<div className="flex items-center gap-2 mb-2">
2128
{/* Compact percentage with LED glow */}
2229
<div className="relative flex items-center justify-center w-6 h-6">
2330
<div
24-
className={`absolute inset-0 rounded-md bg-primary/15 ${isInstalling ? "animate-pulse" : ""}`}
31+
className={`absolute inset-0 rounded-md bg-primary/15 ${isInstalling || indeterminate ? "animate-pulse" : ""}`}
2532
/>
2633
<span className="relative text-[10px] font-bold text-primary tabular-nums">
27-
{isInstalling ? "..." : `${pct}%`}
34+
{isInstalling ? "..." : indeterminate ? "···" : `${pct}%`}
2835
</span>
2936
</div>
3037
<div className="flex-1 min-w-0">
3138
<p className="text-xs font-medium text-foreground truncate">
3239
{isInstalling ? `Installing ${modelName}` : `Downloading ${modelName}`}
3340
</p>
34-
{!isInstalling && (speedText || etaText) && (
41+
{!isInstalling && (indeterminate || speedText || etaText) && (
3542
<div className="flex items-center gap-1.5 mt-0.5">
43+
{indeterminate && (
44+
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
45+
{formatBytes(downloadedBytes)}
46+
</span>
47+
)}
3648
{speedText && (
3749
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
3850
{speedText}
@@ -51,20 +63,24 @@ export function DownloadProgressBar({
5163
</div>
5264
</div>
5365

54-
{/* Progress bar - thinner, premium */}
66+
{/* Progress bar */}
5567
<div
5668
className="w-full rounded-full overflow-hidden bg-white/5 dark:bg-white/3"
5769
style={{ height: 4 }}
5870
>
59-
<div
60-
className={`${isInstalling ? "animate-pulse" : ""} bg-primary shadow-[0_0_8px_oklch(0.62_0.22_260/0.4)]`}
61-
style={{
62-
height: "100%",
63-
width: `${isInstalling ? 100 : Math.min(percentage, 100)}%`,
64-
borderRadius: 9999,
65-
transition: "width 300ms ease-out",
66-
}}
67-
/>
71+
{indeterminate ? (
72+
<div className="h-full w-1/3 rounded-full bg-primary shadow-[0_0_8px_oklch(0.62_0.22_260/0.4)] animate-[indeterminate_1.5s_ease-in-out_infinite]" />
73+
) : (
74+
<div
75+
className={`${isInstalling ? "animate-pulse" : ""} bg-primary shadow-[0_0_8px_oklch(0.62_0.22_260/0.4)]`}
76+
style={{
77+
height: "100%",
78+
width: `${isInstalling ? 100 : Math.min(percentage, 100)}%`,
79+
borderRadius: 9999,
80+
transition: "width 300ms ease-out",
81+
}}
82+
/>
83+
)}
6884
</div>
6985
</div>
7086
);

src/helpers/downloadUtils.js

Lines changed: 66 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -39,83 +39,39 @@ function sleep(ms) {
3939
return new Promise((resolve) => setTimeout(resolve, ms));
4040
}
4141

42-
function resolveRedirects(url, timeout) {
43-
return new Promise((resolve, reject) => {
44-
let redirectCount = 0;
45-
46-
const follow = (currentUrl) => {
47-
if (redirectCount > MAX_REDIRECTS) {
48-
reject(Object.assign(new Error("Too many redirects"), { isHttpError: true }));
49-
return;
50-
}
51-
52-
const client = currentUrl.startsWith("https") ? https : http;
53-
const parsed = new URL(currentUrl);
54-
const req = client.request({
55-
method: "HEAD",
56-
hostname: parsed.hostname,
57-
port: parsed.port,
58-
path: parsed.pathname + parsed.search,
59-
timeout,
60-
headers: { "User-Agent": USER_AGENT },
61-
});
62-
63-
req.on("response", (res) => {
64-
res.resume();
65-
if (
66-
res.statusCode === 301 ||
67-
res.statusCode === 302 ||
68-
res.statusCode === 303 ||
69-
res.statusCode === 307 ||
70-
res.statusCode === 308
71-
) {
72-
const location = res.headers.location;
73-
if (!location) {
74-
reject(
75-
Object.assign(new Error("Redirect without location header"), { isHttpError: true })
76-
);
77-
return;
78-
}
79-
redirectCount++;
80-
follow(location);
81-
return;
82-
}
83-
resolve({ finalUrl: currentUrl, statusCode: res.statusCode });
84-
});
85-
86-
req.on("error", reject);
87-
req.on("timeout", () => {
88-
req.destroy();
89-
reject(Object.assign(new Error("Timeout resolving redirects"), { code: "ETIMEDOUT" }));
90-
});
91-
92-
req.end();
93-
};
94-
95-
follow(url);
96-
});
97-
}
42+
function downloadAttempt(url, tempPath, options) {
43+
const {
44+
timeout,
45+
onProgress,
46+
signal,
47+
startOffset = 0,
48+
expectedSize = 0,
49+
_redirects = 0,
50+
} = options;
9851

99-
function downloadAttempt(url, tempPath, { timeout, onProgress, signal, startOffset }) {
10052
return new Promise((resolve, reject) => {
10153
if (signal?.aborted) {
10254
reject(Object.assign(new Error("Download cancelled"), { isAbort: true }));
10355
return;
10456
}
10557

58+
if (_redirects > MAX_REDIRECTS) {
59+
reject(Object.assign(new Error("Too many redirects"), { isHttpError: true }));
60+
return;
61+
}
62+
10663
const headers = { "User-Agent": USER_AGENT };
10764
if (startOffset > 0) {
10865
headers["Range"] = `bytes=${startOffset}-`;
10966
}
11067

11168
const client = url.startsWith("https") ? https : http;
112-
let activeFile = fs.createWriteStream(tempPath, { flags: startOffset > 0 ? "a" : "w" });
113-
69+
let request = null;
70+
let activeFile = null;
71+
let stallTimer = null;
11472
let downloadedSize = startOffset;
11573
let totalSize = 0;
11674
let lastProgressUpdate = 0;
117-
let request = null;
118-
let stallTimer = null;
11975

12076
const cleanup = () => {
12177
if (stallTimer) {
@@ -126,7 +82,10 @@ function downloadAttempt(url, tempPath, { timeout, onProgress, signal, startOffs
12682
request.destroy();
12783
request = null;
12884
}
129-
activeFile.destroy();
85+
if (activeFile) {
86+
activeFile.destroy();
87+
activeFile = null;
88+
}
13089
};
13190

13291
const onAbort = () => {
@@ -140,20 +99,44 @@ function downloadAttempt(url, tempPath, { timeout, onProgress, signal, startOffs
14099

141100
request = client.get(url, { headers, timeout }, (response) => {
142101
if (signal?.aborted) {
102+
response.resume();
143103
cleanup();
144104
reject(Object.assign(new Error("Download cancelled"), { isAbort: true }));
145105
return;
146106
}
147107

148108
const statusCode = response.statusCode;
149109

110+
// Follow redirects inline — no separate HEAD resolve step needed
111+
if (statusCode >= 300 && statusCode < 400) {
112+
response.resume();
113+
if (signal) signal.onAbort = null;
114+
if (request) {
115+
request.destroy();
116+
request = null;
117+
}
118+
const location = response.headers.location;
119+
if (!location) {
120+
reject(
121+
Object.assign(new Error("Redirect without location header"), { isHttpError: true })
122+
);
123+
return;
124+
}
125+
downloadAttempt(location, tempPath, { ...options, _redirects: _redirects + 1 }).then(
126+
resolve,
127+
reject
128+
);
129+
return;
130+
}
131+
132+
// Content response — create write stream
150133
if (statusCode === 200 && startOffset > 0) {
151134
// Server doesn't support Range — restart from beginning
152135
downloadedSize = 0;
153-
activeFile.destroy();
154136
activeFile = fs.createWriteStream(tempPath, { flags: "w" });
155137
totalSize = parseInt(response.headers["content-length"], 10) || 0;
156138
} else if (statusCode === 206) {
139+
activeFile = fs.createWriteStream(tempPath, { flags: "a" });
157140
const contentRange = response.headers["content-range"];
158141
if (contentRange) {
159142
const match = contentRange.match(/\/(\d+)$/);
@@ -164,8 +147,10 @@ function downloadAttempt(url, tempPath, { timeout, onProgress, signal, startOffs
164147
totalSize = startOffset + contentLength;
165148
}
166149
} else if (statusCode === 200) {
150+
activeFile = fs.createWriteStream(tempPath, { flags: "w" });
167151
totalSize = parseInt(response.headers["content-length"], 10) || 0;
168152
} else {
153+
response.resume();
169154
cleanup();
170155
const err = new Error(`HTTP ${statusCode}`);
171156
err.isHttpError = true;
@@ -174,6 +159,11 @@ function downloadAttempt(url, tempPath, { timeout, onProgress, signal, startOffs
174159
return;
175160
}
176161

162+
// Fall back to caller-provided expected size when Content-Length is missing
163+
if (totalSize <= 0 && expectedSize > 0) {
164+
totalSize = expectedSize;
165+
}
166+
177167
const resetStallTimer = () => {
178168
if (stallTimer) clearTimeout(stallTimer);
179169
stallTimer = setTimeout(() => {
@@ -240,9 +230,12 @@ function downloadAttempt(url, tempPath, { timeout, onProgress, signal, startOffs
240230
});
241231

242232
function emitProgress() {
243-
if (!onProgress || totalSize <= 0) return;
233+
if (!onProgress) return;
244234
const now = Date.now();
245-
if (now - lastProgressUpdate >= PROGRESS_THROTTLE_MS || downloadedSize >= totalSize) {
235+
if (
236+
now - lastProgressUpdate >= PROGRESS_THROTTLE_MS ||
237+
(totalSize > 0 && downloadedSize >= totalSize)
238+
) {
246239
lastProgressUpdate = now;
247240
onProgress(downloadedSize, totalSize);
248241
}
@@ -256,6 +249,7 @@ async function downloadFile(url, destPath, options = {}) {
256249
timeout = DEFAULT_TIMEOUT,
257250
maxRetries = DEFAULT_MAX_RETRIES,
258251
signal,
252+
expectedSize = 0,
259253
} = options;
260254

261255
const tempPath = `${destPath}.tmp`;
@@ -273,8 +267,6 @@ async function downloadFile(url, destPath, options = {}) {
273267
// No existing temp file
274268
}
275269

276-
const { finalUrl } = await resolveRedirects(url, timeout);
277-
278270
let lastError = null;
279271

280272
for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -297,7 +289,13 @@ async function downloadFile(url, destPath, options = {}) {
297289
}
298290

299291
try {
300-
await downloadAttempt(finalUrl, tempPath, { timeout, onProgress, signal, startOffset });
292+
await downloadAttempt(url, tempPath, {
293+
timeout,
294+
onProgress,
295+
signal,
296+
startOffset,
297+
expectedSize,
298+
});
301299

302300
// Atomic move to final path
303301
try {

src/helpers/parakeet.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ class ParakeetManager {
299299
await downloadFile(modelConfig.url, archivePath, {
300300
timeout: 600000,
301301
signal,
302+
expectedSize: modelConfig.size,
302303
onProgress: (downloadedBytes, totalBytes) => {
303304
if (progressCallback) {
304305
progressCallback({
@@ -411,9 +412,15 @@ class ParakeetManager {
411412
}
412413
}
413414

414-
const encoderPath = path.join(targetDir, "encoder.int8.onnx");
415-
if (!fs.existsSync(encoderPath)) {
416-
throw new Error("Extracted model is missing required files (encoder.int8.onnx)");
415+
const requiredFiles = [
416+
"encoder.int8.onnx",
417+
"decoder.int8.onnx",
418+
"joiner.int8.onnx",
419+
"tokens.txt",
420+
];
421+
const missing = requiredFiles.filter((f) => !fs.existsSync(path.join(targetDir, f)));
422+
if (missing.length > 0) {
423+
throw new Error(`Extracted model is missing required files: ${missing.join(", ")}`);
417424
}
418425

419426
await fsPromises.rm(extractDir, { recursive: true, force: true });

src/helpers/whisper.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ class WhisperManager {
405405
await downloadFile(modelConfig.url, modelPath, {
406406
timeout: 600000,
407407
signal,
408+
expectedSize: modelConfig.size,
408409
onProgress: (downloadedBytes, totalBytes) => {
409410
if (progressCallback) {
410411
progressCallback({

src/index.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,16 @@ a:hover {
421421
}
422422
}
423423

424+
/* Indeterminate progress bar sweep */
425+
@keyframes indeterminate {
426+
0% {
427+
transform: translateX(-100%);
428+
}
429+
100% {
430+
transform: translateX(300%);
431+
}
432+
}
433+
424434
::-webkit-scrollbar {
425435
width: 6px;
426436
height: 6px;

0 commit comments

Comments
 (0)