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
14 changes: 14 additions & 0 deletions studio/backend/hub/schemas/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class DownloadStartResponse(BaseModel):
state: str
accepted: bool
generation: int
# The transport the job is really on: the one the backend resolved for a
# fresh start, or the running job's own when this start adopted one.
# Either can differ from what the client asked for.
transport: Optional[str] = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include cancel markers in adopted start responses

When another client starts the same key while the live job has already fallen back from Xet to HTTP, this start response is the only data source for the non-opts.adopt adoption path. Fresh evidence beyond the earlier retry-semantics concern is that active downloads now expose cancel_transport, but the start response only exposes transport, so the frontend records http with no cancelTransport and offers Pause even though cancelling still writes the Xet marker and leaves a Redownload-only partial; include the marker in both model and dataset accepted-start responses.

Useful? React with 👍 / 👎.

# Set only when an adopted job had fallen back from Xet to HTTP: stopping
# it still writes the original marker, so it is a restart, not a resume.
cancel_transport: Optional[str] = None


class CancelDownloadResponse(BaseModel):
Expand All @@ -91,6 +98,10 @@ class ActiveDownload(BaseModel):
repo_id: Optional[str] = None
variant: Optional[str] = None
transport: Optional[str] = None
# Set only on a job that fell back from Xet to HTTP mid-flight: cancelling
# it still writes the original transport's marker, so the partial is
# restart-only even though the worker is on resumable HTTP.
cancel_transport: Optional[str] = None
state: str
files: Optional[List[str]] = Field(
None,
Expand Down Expand Up @@ -189,6 +200,9 @@ class DatasetDownloadStartResponse(BaseModel):
state: str
accepted: bool
generation: int
# The transport the job is really on, and its cancel marker (see above).
transport: Optional[str] = None
cancel_transport: Optional[str] = None


class CancelDatasetDownloadResponse(BaseModel):
Expand Down
9 changes: 9 additions & 0 deletions studio/backend/hub/services/datasets/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ async def download_dataset_response(
"state": claim_state,
"accepted": _registry.adoptable(key),
"generation": generation,
# An adopted job keeps the transport it started on, so report it
# rather than let the caller assume the one it asked for.
"transport": _registry.job_transport(key),
# And its cancel marker: a run that fell back from Xet to HTTP
# still cancels into a restart-only partial.
"cancel_transport": _registry.job_cancel_transport(key),
}
download_manifest.clear_cancel_marker(
"dataset",
Expand Down Expand Up @@ -220,6 +226,9 @@ async def download_dataset_response(
"state": state,
"accepted": True,
"generation": generation,
# See models: the resolved transport, which a downgrade can make
# different from the one requested.
"transport": transport,
}


Expand Down
3 changes: 3 additions & 0 deletions studio/backend/hub/services/download_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,9 @@ def active_download_refs(
repo_id = ref_repo_id,
variant = variant,
transport = metadata.transport if metadata is not None else None,
cancel_transport = (
metadata.cancel_marker_transport if metadata is not None else None
),
state = ref.state,
generation = ref.generation,
files = scoped_files or None,
Expand Down
10 changes: 10 additions & 0 deletions studio/backend/hub/services/models/downloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,12 @@ async def download_model_response(
"state": claim_state,
"accepted": _registry.adoptable(key),
"generation": generation,
# An adopted job keeps the transport it started on, so report it
# rather than let the caller assume the one it asked for.
"transport": _registry.job_transport(key),
# And its cancel marker: a run that fell back from Xet to HTTP
# still cancels into a restart-only partial.
"cancel_transport": _registry.job_cancel_transport(key),
}
download_manifest.clear_cancel_marker(
"model",
Expand Down Expand Up @@ -349,6 +355,10 @@ async def download_model_response(
"state": state,
"accepted": True,
"generation": generation,
# The transport that was actually resolved: an explicit "xet" is
# downgraded to HTTP where hf_xet is unavailable, and a client that
# assumed its request stood would offer the wrong stop control.
"transport": transport,
}


Expand Down
14 changes: 14 additions & 0 deletions studio/backend/hub/utils/download_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,20 @@ def set_error_unless_cancelled(
metadata = replace(metadata, transport = marker_transport)
return terminal_state, metadata

def job_transport(self, key: str) -> Optional[str]:
"""The transport a live job is running on. None when it has no metadata."""
key = normalize_job_key(key)
with self._lock:
metadata = self._metadata.get(key)
return metadata.transport if metadata is not None else None

def job_cancel_transport(self, key: str) -> Optional[str]:
"""A live job's cancel marker, when a fallback left one. See metadata."""
key = normalize_job_key(key)
with self._lock:
metadata = self._metadata.get(key)
return metadata.cancel_marker_transport if metadata is not None else None

def update_job_transport(self, key: str, transport: str) -> None:
key = normalize_job_key(key)
with self._lock:
Expand Down
127 changes: 127 additions & 0 deletions studio/backend/tests/test_download_adoption_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""A start that adopts a running job reports the transport that job is on."""

from hub.utils.download_registry import DownloadRegistry


def _claim(registry, key, transport):
accepted, state = registry.claim(
key,
transport,
repo_type = "model",
repo_id = key.split("::", 1)[0],
)
return accepted, state


def test_a_running_job_reports_the_transport_it_started_on():
registry = DownloadRegistry()
assert _claim(registry, "unsloth/Qwen3-4B-GGUF", "xet") == (True, "running")

# The second client asked for HTTP; the claim is refused and it adopts.
accepted, state = _claim(registry, "unsloth/Qwen3-4B-GGUF", "http")
assert accepted is False and state == "running"
assert registry.adoptable("unsloth/Qwen3-4B-GGUF") is True
# What it must be told, rather than the http it asked for: pausing a Xet
# run promises a resume that does not exist.
assert registry.job_transport("unsloth/Qwen3-4B-GGUF") == "xet"


def test_an_unknown_job_has_no_transport_to_report():
registry = DownloadRegistry()
assert registry.job_transport("unsloth/never-started") is None


def test_a_job_claimed_without_metadata_reports_nothing():
registry = DownloadRegistry()
assert registry.claim("unsloth/bare", "http")[0] is True
assert registry.job_transport("unsloth/bare") is None


def test_a_fresh_start_reports_the_transport_the_backend_resolved(monkeypatch):
"""An explicit Xet request is downgraded where hf_xet is unavailable, and a
client that assumed its request stood shows Cancel for a resumable HTTP
transfer."""
from hub.services import download_lifecycle

monkeypatch.setattr(
download_lifecycle.download_registry,
"download_transport_unavailable_reason",
lambda transport: "hf_xet is not installed" if transport == "xet" else None,
)
use_xet, _reason = download_lifecycle.resolve_requested_use_xet("xet", True)
assert use_xet is False, "the downgrade this reports is what the client must be told"
assert download_lifecycle.resolve_transport(use_xet) == "http"


def test_an_available_xet_request_is_left_alone(monkeypatch):
from hub.services import download_lifecycle

monkeypatch.setattr(
download_lifecycle.download_registry,
"download_transport_unavailable_reason",
lambda transport: None,
)
use_xet, _reason = download_lifecycle.resolve_requested_use_xet("xet", True)
assert download_lifecycle.resolve_transport(use_xet) == "xet"


def test_a_fallback_run_publishes_the_marker_that_decides_its_stop_control():
"""The Xet-to-HTTP retry reclaims as HTTP but keeps the Xet cancel marker,
so stopping it is a restart even though the worker is on HTTP."""
from hub.services.download_lifecycle import active_download_refs

registry = DownloadRegistry()
key = "unsloth/Qwen3-4B-GGUF"
assert (
registry.claim(
key,
"http",
repo_type = "model",
repo_id = key,
cancel_marker_transport = "xet",
)[0]
is True
)

ref = active_download_refs(registry, key, with_variant = True)[0]
assert ref.transport == "http", "the worker really is on HTTP"
assert ref.cancel_transport == "xet", "but cancelling writes the Xet marker"


def test_an_ordinary_run_publishes_no_cancel_marker():
from hub.services.download_lifecycle import active_download_refs

registry = DownloadRegistry()
key = "unsloth/Qwen3-4B-GGUF"
registry.claim(key, "http", repo_type = "model", repo_id = key)
ref = active_download_refs(registry, key, with_variant = True)[0]
assert ref.cancel_transport is None


def test_an_adopted_fallback_run_reports_its_marker_to_the_new_client():
"""The start response is the only source for that adoption path, so it has
to carry the marker as well as the live transport."""
registry = DownloadRegistry()
key = "unsloth/Qwen3-4B-GGUF"
registry.claim(
key,
"http",
repo_type = "model",
repo_id = key,
cancel_marker_transport = "xet",
)
# What the rejected second claim then reports.
assert registry.adoptable(key) is True
assert registry.job_transport(key) == "http"
assert registry.job_cancel_transport(key) == "xet"


def test_an_ordinary_adopted_run_reports_no_marker():
registry = DownloadRegistry()
key = "unsloth/Qwen3-4B-GGUF"
registry.claim(key, "xet", repo_type = "model", repo_id = key)
assert registry.job_cancel_transport(key) is None
assert registry.job_cancel_transport("unsloth/never-started") is None
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export function DatasetDownloadSection({
loading={downloadAction.starting}
isPartial={downloadAction.isPartial}
partialTransport={downloadAction.partialTransport}
stopMode={downloadAction.stopMode}
progressPercent={downloadAction.progressPercent}
disabled={downloadAction.disabled}
onClick={downloadAction.onClick}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
// 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 { Spinner } from "@/components/ui/spinner";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { Cancel01Icon, PauseIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";

/** What stopping this download costs. HTTP keeps the partial to continue from,
* so it is a pause; Xet has to start over, so it is a cancel. */
export type DownloadStopMode = "cancel" | "pause";

/**
* Inspector action-button affordance during a download: spinner that cross-fades
* to a cancel glyph on `.hub-action-btn` hover, in the same 16x16 slot so the
* percentage label never shifts. The swap is pure CSS
* (`.hub-action-btn:hover .hub-cta-indicator-*`); the component only carries
* the marker classes.
* The stop glyph beside the percentage during a download. Always visible: it is
* the only way to stop, so it should not need a hover to be found. Sits in a
* fixed 16x16 slot so the percentage never shifts.
*/
export function DownloadCancelIndicator() {
export function DownloadStopIndicator({ mode }: { mode: DownloadStopMode }) {
return (
<span className="hub-cta-indicator">
<Spinner className="hub-cta-indicator-spinner" />
<HugeiconsIcon
icon={Cancel01Icon}
icon={mode === "pause" ? PauseIcon : Cancel01Icon}
strokeWidth={1.75}
className="hub-cta-indicator-cancel"
/>
</span>
);
Expand Down
22 changes: 18 additions & 4 deletions studio/frontend/src/features/hub/catalog/download-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ import {
type DownloadJob,
type DownloadJobProgress,
} from "../download-manager";
import { DownloadCancelIndicator } from "./download-cancel-indicator";
import {
type DownloadStopMode,
DownloadStopIndicator,
} from "./download-cancel-indicator";
import { TransportConflictDialog } from "./transport-conflict-dialog";
import {
downloadActionAriaLabel,
Expand Down Expand Up @@ -59,7 +62,15 @@ export function DownloadCard({
<div className="hub-download-card">
<div className="group/dl flex items-center">{children}</div>
{progress && (
<DownloadProgressBar progress={progress} bytesPerSec={job.bytesPerSec} />
// Match the row's inner text bounds: the trigger and the action
// button both inset 12px, so the bar lines up with the quant label
// on the left and the percentage on the right.
<div className="px-3">
<DownloadProgressBar
progress={progress}
bytesPerSec={job.bytesPerSec}
/>
</div>
)}
</div>
<TransportConflictDialog
Expand Down Expand Up @@ -223,6 +234,7 @@ export function DownloadActionButton({
loading = false,
isPartial = false,
partialTransport = null,
stopMode = "cancel",
progressPercent = null,
disabled,
onClick,
Expand All @@ -233,6 +245,8 @@ export function DownloadActionButton({
loading?: boolean;
isPartial?: boolean;
partialTransport?: string | null;
/** What stopping the running job costs; see downloadStopMode. */
stopMode?: DownloadStopMode;
progressPercent?: number | null;
disabled: boolean;
onClick: () => void;
Expand All @@ -243,7 +257,7 @@ export function DownloadActionButton({
type="button"
disabled={disabled}
onClick={onClick}
aria-label={downloadActionAriaLabel(downloading, cancelling)}
aria-label={downloadActionAriaLabel(downloading, cancelling, stopMode)}
className={cn(
"hub-action-btn w-28",
(loading || cancelling) && "opacity-70",
Expand All @@ -260,7 +274,7 @@ export function DownloadActionButton({
</span>
) : downloading ? (
<>
<DownloadCancelIndicator />
<DownloadStopIndicator mode={stopMode} />
{progressPercent != null ? `${progressPercent}%` : null}
</>
) : loading ? (
Expand Down
12 changes: 7 additions & 5 deletions studio/frontend/src/features/hub/catalog/gguf-download-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import {
} from "../lib/model-identity";
import { useHfTokenStore } from "../stores/hf-token-store";
import { DotTag } from "./dot-tag";
import { DownloadCancelIndicator } from "./download-cancel-indicator";
import { DownloadStopIndicator } from "./download-cancel-indicator";
import {
CardDivider,
DeleteConfirmDialog,
Expand Down Expand Up @@ -942,9 +942,11 @@ export function GgufDownloadCard({
className="hub-menu-trigger flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-foreground/[0.04] data-[state=open]:bg-foreground/[0.06] dark:hover:bg-white/[0.04] dark:data-[state=open]:bg-white/[0.06]"
>
{/* Quant label + status tags travel together as one left-aligned
group so the fit-info icon never floats orphaned from its tags;
only the chevron pins right, the standard select affordance. */}
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-ui-12 text-muted-foreground">
group so the fit-info icon never floats orphaned from its tags.
The group sizes to its content (it still shrinks when the row
is tight) so the chevron follows the tags instead of stranding
itself at the far edge of a full-width trigger. */}
<span className="flex min-w-0 items-center gap-2 overflow-hidden text-ui-12 text-muted-foreground">
{selected ? (
<QuantBadge
quant={selectedLabel ?? selected.quant}
Expand Down Expand Up @@ -1119,7 +1121,7 @@ export function GgufDownloadCard({
</span>
) : downloadingThisVariant ? (
<span className="inline-flex items-center gap-2">
<DownloadCancelIndicator />
<DownloadStopIndicator mode={downloadAction.stopMode} />
{downloadAction.progressPercent != null
? `${downloadAction.progressPercent}%`
: null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DownloadActionButton,
DownloadCard,
} from "./download-card";
import { downloadStopMode } from "./use-download-card-state";

export function GgufDownloadStatusCard({
job,
Expand Down Expand Up @@ -101,6 +102,7 @@ export function GgufDownloadingFallbackCard({
downloading
cancelling={cancelling}
progressPercent={Math.round(Math.min(progress.fraction, 1) * 100)}
stopMode={downloadStopMode(job.transport, null, job.cancelTransport)}
disabled={cancelling}
onClick={() => void job.cancelDownload(progress.variant)}
/>
Expand Down
Loading
Loading