Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions studio/frontend/src/features/chat/hooks/use-transfer-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
import { useEffect, useRef, useState } from "react";

import {
appendSample,
computeTransferStats,
type TransferSample,
type TransferStats,
} from "../utils/transfer-stats";
appendSample,
computeTransferStats,
} from "@/lib/transfer-stats";

export type { TransferStats } from "../utils/transfer-stats";
export type { TransferStats } from "@/lib/transfer-stats";

export function useTransferStats(
bytes: number | null | undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export const POLL_DEGRADED_AFTER_MS = 30_000;
export const POLL_DEGRADED_MESSAGE =
"Couldn't update download status. The download may still be running.";
export const TRANSPORT_STATUS_TIMEOUT_MS = 3_000;
export const SPEED_EMA_WEIGHT = 0.7;
export const MAX_PROGRESS_FRACTION = 0.99;
export const CANCEL_WATCHDOG_MS = 20_000;
export const IDLE_EVICT_GRACE_MS = 60_000;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

import type { TransferSample } from "@/lib/transfer-stats";
import type { InventoryHint } from "../inventory/types";
import type { DownloadJobState } from "./api";
import type { DownloadKind } from "./constants";
Expand Down Expand Up @@ -63,7 +64,8 @@ export interface JobRuntime {
inFlight: boolean;
cancelRequested: boolean;
watchdog: number | null;
speedSample: { bytes: number; tMs: number } | null;
/** Rolling byte samples behind the stability-gated rate/ETA. */
speedSamples: TransferSample[];
idleSinceMs: number | null;
lastProgressPollAt: number | null;
pollFailureStartedAt: number | null;
Expand Down
34 changes: 13 additions & 21 deletions studio/frontend/src/features/hub/download-manager/poll-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { invalidateGgufVariantsCache } from "../inventory/api";
import { getHfToken } from "../stores/hf-token-store";
import { bumpInventoryVersion } from "../stores/inventory-events";
import { toast } from "@/lib/toast";
import { appendSample, computeTransferStats } from "@/lib/transfer-stats";
import {
getActiveModelDownloads,
getDatasetDownloadStatus,
Expand All @@ -28,7 +29,6 @@ import {
POLL_JITTER_MS,
PROGRESS_POLL_BACKOFF_INTERVAL_MS,
PROGRESS_POLL_INTERVAL_MS,
SPEED_EMA_WEIGHT,
ACTIVE_STATES,
TERMINAL_DISPLAY_STATES,
} from "./download-manager-config";
Expand Down Expand Up @@ -234,7 +234,7 @@ function markPollFailure(key: string, rt: JobRuntime): void {
const now = Date.now();
rt.pollFailureStartedAt ??= now;
if (now - rt.pollFailureStartedAt < POLL_DEGRADED_AFTER_MS) return;
rt.speedSample = null;
rt.speedSamples.length = 0;
patchJob(key, {
error: POLL_DEGRADED_MESSAGE,
bytesPerSec: 0,
Expand Down Expand Up @@ -364,27 +364,19 @@ async function finalizeTerminalStatus(
}
}

// Rolling-window rate, withheld until the window is trustworthy. The old EMA
// published its first sample verbatim and decayed toward -- never reaching --
// zero while stalled, so ramp-up and idle ticks produced "753d 5h left" (#7667).
// 0 hides both labels, as the training-start overlay already does.
function applySpeedSample(
rt: JobRuntime,
current: ManagedDownload,
downloadedBytes: number,
expectedBytes: number,
nowMs: number,
): number {
const last = rt.speedSample;
let bytesPerSec = last ? current.bytesPerSec : 0;
if (last) {
const dt = (nowMs - last.tMs) / 1000;
const db = Math.max(0, downloadedBytes - last.bytes);
if (dt > 0) {
const sample = db / dt;
bytesPerSec =
bytesPerSec > 0
? bytesPerSec * SPEED_EMA_WEIGHT + sample * (1 - SPEED_EMA_WEIGHT)
: sample;
}
}
rt.speedSample = { bytes: downloadedBytes, tMs: nowMs };
return bytesPerSec;
appendSample(rt.speedSamples, nowMs / 1000, downloadedBytes);
const stats = computeTransferStats(rt.speedSamples, expectedBytes);
return stats.stable ? stats.rateBytesPerSecond : 0;
}

function reconcileProgressAndSpeed(
Expand All @@ -398,7 +390,7 @@ function reconcileProgressAndSpeed(
resolveProgressUpdate(current, progressResp, {
resetMonotonic: generationChanged,
});
const bytesPerSec = applySpeedSample(rt, current, downloadedBytes, Date.now());
const bytesPerSec = applySpeedSample(rt, downloadedBytes, expected, Date.now());
patchJob(key, {
expectedBytes: expected,
downloadedBytes,
Expand Down Expand Up @@ -457,7 +449,7 @@ async function tick(key: string): Promise<void> {
return;
}
if (typeof document !== "undefined" && document.hidden) {
rt.speedSample = null;
rt.speedSamples.length = 0;
return;
}
if (rt.inFlight) return;
Expand Down Expand Up @@ -622,7 +614,7 @@ export async function startJob(
inFlight: false,
cancelRequested: adoptingCancel,
watchdog: null,
speedSample: null,
speedSamples: [],
idleSinceMs: null,
lastProgressPollAt: null,
pollFailureStartedAt: null,
Expand Down
28 changes: 16 additions & 12 deletions studio/frontend/src/features/hub/lib/format.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const HOURS_PER_DAY = 24;
const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
const MAX_DISPLAYABLE_ETA_SECONDS = HOURS_PER_DAY * SECONDS_PER_HOUR;

export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "N/A";
if (bytes === 0) return "0 B";
Expand All @@ -24,23 +30,21 @@ export function formatRate(bytesPerSec: number): string {
return `${formatBytes(bytesPerSec)}/s`;
}

// A day or more collapses to "> 24h left": a precise multi-day figure reads as
// broken, but hiding it leaves a genuinely slow download with no estimate.
export function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "";
const s = Math.round(seconds);
if (s < 60) return `${s}s left`;
if (s < 3600) {
const m = Math.floor(s / 60);
const rem = s % 60;
if (s >= MAX_DISPLAYABLE_ETA_SECONDS) return `> ${HOURS_PER_DAY}h left`;
if (s < SECONDS_PER_MINUTE) return `${s}s left`;
if (s < SECONDS_PER_HOUR) {
const m = Math.floor(s / SECONDS_PER_MINUTE);
const rem = s % SECONDS_PER_MINUTE;
return rem ? `${m}m ${rem}s left` : `${m}m left`;
}
if (s < 86400) {
const h = Math.floor(s / 3600);
const rem = Math.floor((s % 3600) / 60);
return rem ? `${h}h ${rem}m left` : `${h}h left`;
}
const d = Math.floor(s / 86400);
const rem = Math.floor((s % 86400) / 3600);
return rem ? `${d}d ${rem}h left` : `${d}d left`;
const h = Math.floor(s / SECONDS_PER_HOUR);
const rem = Math.floor((s % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
return rem ? `${h}h ${rem}m left` : `${h}h left`;
}

export function ownerOf(id: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* Pure, framework-free math behind {@link useTransferStats}.
*
* Split out for unit-testing without React, and so the training-start overlay,
* chat download toast, and model-load UI share identical rate/ETA semantics.
* No React/timers -- the caller owns the sample buffer and clock.
* chat download toast, model-load UI and hub download manager share identical
* rate/ETA semantics. No React/timers -- the caller owns the buffer and clock.
*/

export type TransferSample = { t: number; b: number };
Expand Down
61 changes: 61 additions & 0 deletions studio/frontend/tests/hub-download-rate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

// The hub download manager now derives its rate from the shared rolling-window
// estimator instead of an EMA seeded by the first sample (#7667). These pin the
// gating the progress bar relies on: no rate (and so no ETA) until the window is
// trustworthy, and no tiny positive rate while a transfer is stalled.

import assert from "node:assert/strict";
import test from "node:test";

import {
type TransferSample,
appendSample,
computeTransferStats,
} from "../src/lib/transfer-stats.ts";

const TOTAL = 6.8e9;
const MB = 1e6;

/** Mirrors poll-loop's applySpeedSample: the rate published to the UI. */
function publishedRate(
samples: TransferSample[],
t: number,
b: number,
): number {
appendSample(samples, t, b);
const stats = computeTransferStats(samples, TOTAL);
return stats.stable ? stats.rateBytesPerSecond : 0;
}

test("connection ramp-up publishes no rate until the window is trustworthy", () => {
const samples: TransferSample[] = [];
assert.equal(publishedRate(samples, 0, 0), 0);
assert.equal(publishedRate(samples, 1, 100), 0);
assert.equal(publishedRate(samples, 2, 200), 0);
// Only now are there 3 samples spanning 3s of forward progress.
assert.ok(publishedRate(samples, 3, 30 * MB) > 0);
});

test("a steady transfer reports its true rate", () => {
const samples: TransferSample[] = [];
let rate = 0;
for (let t = 0; t <= 10; t++) rate = publishedRate(samples, t, t * 20 * MB);
assert.ok(Math.abs(rate - 20 * MB) < 1);
});

test("a stall reports no rate instead of decaying toward zero", () => {
const samples: TransferSample[] = [];
for (let t = 0; t <= 10; t++) publishedRate(samples, t, t * 20 * MB);
let rate = 0;
for (let t = 11; t <= 40; t++) rate = publishedRate(samples, t, 10 * 20 * MB);
assert.equal(rate, 0);
});

test("a restart drops the samples from the previous run", () => {
const samples: TransferSample[] = [];
for (let t = 0; t <= 10; t++) publishedRate(samples, t, t * 20 * MB);
assert.equal(publishedRate(samples, 11, 0), 0);
assert.equal(samples.length, 1);
});
53 changes: 53 additions & 0 deletions studio/frontend/tests/hub-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

import assert from "node:assert/strict";
import test from "node:test";

import { formatEta } from "../src/features/hub/lib/format.ts";

const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// The reported case: 6.8 GB left at 102 B/s rendered "753d 5h left".
const REPRO_SECONDS = (753 * 24 + 5) * HOUR;

test("sub-hour ETAs keep their seconds and minutes", () => {
assert.equal(formatEta(1), "1s left");
assert.equal(formatEta(MINUTE - 1), "59s left");
assert.equal(formatEta(MINUTE), "1m left");
assert.equal(formatEta(MINUTE + 1), "1m 1s left");
assert.equal(formatEta(HOUR - 1), "59m 59s left");
});

test("hour ETAs drop the seconds", () => {
assert.equal(formatEta(HOUR), "1h left");
assert.equal(formatEta(HOUR + MINUTE), "1h 1m left");
assert.equal(formatEta(DAY - 1), "23h 59m left");
});

test("an ETA of a day or more collapses to a bound instead of a day count", () => {
assert.equal(formatEta(DAY), "> 24h left");
assert.equal(formatEta(DAY + 1), "> 24h left");
assert.equal(formatEta(REPRO_SECONDS), "> 24h left");
assert.equal(formatEta(Number.MAX_SAFE_INTEGER), "> 24h left");
});

test("the cutoff applies to the rounded value, so it starts at 86399.5s", () => {
assert.equal(formatEta(DAY - 0.51), "23h 59m left");
assert.equal(formatEta(DAY - 0.5), "> 24h left");
});

test("unusable inputs render nothing rather than a bogus estimate", () => {
assert.equal(formatEta(Number.NaN), "");
assert.equal(formatEta(Number.POSITIVE_INFINITY), "");
assert.equal(formatEta(Number.NEGATIVE_INFINITY), "");
assert.equal(formatEta(0), "");
assert.equal(formatEta(-1), "");
});

test("no ETA is ever reported in days", () => {
for (let s = 1; s <= 3 * DAY; s += 137) {
assert.doesNotMatch(formatEta(s), /\d+d\b/);
}
});
Loading