* fix(studio/colab): merge iframe+keepalive into start(), add proxy_headers to uvicorn - Move serve_kernel_port_as_iframe and keepalive loop into colab.start() so both run in the same cell execution context, eliminating the race where the proxy URL was shown before the iframe cell had a chance to run - Add a 2s sleep after run_server() before show_link() to give Colab's proxy infrastructure time to register the bound port - Add proxy_headers=True and forwarded_allow_ips="*" to uvicorn Config so X-Forwarded-Proto/Host from Colab's reverse proxy are trusted - Simplify notebook start cell (no more separate iframe cell needed) * fix(studio/colab): fix iframe blocking and server thread crash in Colab Two root causes for the long-standing proxy/iframe breakage: 1. SecurityHeadersMiddleware set X-Frame-Options: DENY and frame-ancestors 'none' unconditionally, blocking serve_kernel_port_as_iframe regardless of server health. Fix: detect Colab via COLAB_BACKEND_URL/COLAB_GPU env vars, relax frame-ancestors to *.prod.colab.dev and omit X-Frame-Options. 2. asyncio.run() in the daemon thread conflicted with nest_asyncio's global patches applied on the main thread, causing the server to crash silently after ready_event fired. Fix: use explicit new_event_loop() + run_until_complete() in the daemon thread to bypass nest_asyncio's asyncio.run patch. Also replace blind time.sleep(2) with a health endpoint poll so the link and iframe are only shown once the server is truly reachable. * fix(studio/colab): use reliable /content + google.colab path for Colab detection COLAB_BACKEND_URL and COLAB_GPU env vars aren't consistently set across all Colab runtime versions. Use /content dir + google.colab package path as a more reliable signal, computed once at module load. * fix(studio/colab): fix port mismatch, health-check silence, and CSP framing Four bugs causing the iframe and URL button to always fail: 1. Port not propagated back: run_server auto-increments when 8888 is taken, but start() kept using the original port for show_link() and serve_kernel_port_as_iframe() — now reads app.state.server_port. 2. Silent health-check failure: the poll loop never checked whether any attempt succeeded; on all-fail it continued and showed a dead link — now exits early with a clear error message. 3. CSP frame-ancestors too narrow: '*.prod.colab.dev' only matches one subdomain level; actual Colab proxy URLs are two levels deep (e.g. foo.region.prod.colab.dev), and the parent frame may also be colab.research.google.com or a sandboxed null-origin output iframe — changed to '*' in Colab mode (single-user sandbox, no security loss). 4. _IS_COLAB detection hardcoded python3.10/3.11 paths: Python 3.12+ Colab runtimes wouldn't match when env vars aren't set — replaced with a glob over python3.*/dist-packages/google/colab. * fix(studio/colab): harden Colab startup against every known failure mode colab.py: - get_colab_url: retry eval_js up to 3x (10s timeout each), validate that result is a real https:// URL containing the port before accepting it; log a clear warning when falling back to localhost - show_link: safe short_url truncation (try/except around str.index so an unexpected URL shape never blocks the link card from rendering); also emit the URL via logger so it's visible in cell text output even if HTML display is suppressed - start: detect "already running" at entry — on cell re-run Studio is still healthy on port 8888; skip re-launch and go straight to show+iframe so the user never ends up with mismatched port state - start: wrap run_server in try/except (SystemExit + Exception) so startup errors surface as readable messages rather than cell crashes - start: check frontend_path/index.html exists, not just the directory - start: remove unused `import sys` - start / keepalive: catch KeyboardInterrupt so interrupting the cell prints a clean "stopped" message instead of a raw traceback - extract _is_studio_healthy() and _show_and_embed() helpers to deduplicate the fast-path and normal-path logic main.py: - _build_csp: in Colab mode, extend script-src to include *.prod.colab.dev and *.googleusercontent.com (Colab injects scripts from these origins into the output iframe scaffolding) - _build_csp: in Colab mode, extend connect-src with blob:, data:, wss://*.prod.colab.dev, and wss://*.googleusercontent.com so WebSocket streams and Colab kernel traffic are not blocked by CSP * fix(studio/colab): fix iframe width responsiveness and height sizing Replace serve_kernel_port_as_iframe with a raw CSS iframe for two reasons: 1. Width responsiveness: serve_kernel_port_as_iframe sets the width as an HTML attribute (width="100%") which Colab's output machinery can bake into a fixed pixel value on first render, causing the Studio to stop following the notebook panel width when it opens/closes or the window resizes. A CSS style property (style="width:100%") participates in normal reflow and always tracks the parent container width. 2. Height sizing: the hardcoded height=1200 was too tall on short monitors (forced outer-page scroll) and wasted space on tall ones. A small JS snippet reads screen.availHeight and sets height to ~82% of the screen, clamped to [600, 1100]px, with a resize listener that re-fits on zoom changes and panel open/close events. Also eliminate the double eval_js call: _show_and_embed now fetches the Colab proxy URL once and passes it to show_link via the new _url kwarg, so google.colab.kernel.proxyPort is only called once per invocation. Falls back to serve_kernel_port_as_iframe if IPython.display.HTML is unavailable for any reason. * fix(studio/colab): fix link button + add fullscreen hover button to iframe Link button: target="_blank" is blocked by Colab's output sandbox. Switch to onclick="window.open(url,'_blank')" which the sandbox allows. Fullscreen: add a small button that appears on hover in the top-right corner of the iframe. Clicking it calls requestFullscreen() on the wrapper div and stretches the iframe to 100vh/100vw. Exits back to normal on fullscreen change. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * revert(studio/colab): remove fullscreen button * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio/colab): address review feedback - Wrap both urlopen calls in with statements to prevent socket/fd leaks - Replace JS resize listener with CSS height:82vh — simpler, responsive, and no risk of leaked window listeners on cell re-runs - Use importlib.util.find_spec("google.colab") instead of a glob path to detect Colab; more robust across Python versions and venv layouts * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio/colab): fall back to href navigation when window.open is blocked window.open from a cross-origin sandboxed Colab output iframe can be silently blocked by the browser (returns null, no exception). The old code returned false unconditionally, so a blocked popup left the button doing nothing. Now: if window.open succeeds the new tab opens and the href is suppressed; if it returns null the browser follows the href, navigating the output cell to Studio — always does something useful. * fix(studio/colab): remove button, give iframe a branded header bar The "Open Unsloth Studio" button was unreliable in Colab's sandboxed output context regardless of how window.open was called. Since the iframe already loads Studio inline, the button added no value and confused users with a URL that 404s outside the output cell. Replace the separate link card + bare iframe with a single block: a slim black header bar (Unsloth logo + truncated URL) flush on top of the full-height responsive iframe. Cleaner and removes the broken button entirely. * studio: gate uvicorn proxy_headers/forwarded_allow_ips behind _IS_COLAB forwarded_allow_ips="*" was applied unconditionally, so every Studio deployment trusted X-Forwarded-* headers from any client. Only Colab needs that, because its reverse proxy fronts the kernel. For a normal local/standalone Studio this is an unwanted relaxation, especially when bound to 0.0.0.0. Now proxy_headers/forwarded_allow_ips are only set when _IS_COLAB. Standalone runs fall back to uvicorn's defaults (proxy_headers honored from loopback only), restoring the prior security posture, while Colab keeps the wide trust its proxy requires. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
282 lines
10 KiB
Python
282 lines
10 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""
|
|
Colab-specific helpers for running Unsloth Studio.
|
|
Uses Colab's built-in proxy - no external tunneling needed!
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
|
|
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
|
|
# See: https://github.com/python/cpython/issues/102396
|
|
_backend_dir = str(Path(__file__).parent)
|
|
if _backend_dir not in sys.path:
|
|
sys.path.insert(0, _backend_dir)
|
|
import _platform_compat # noqa: F401
|
|
|
|
|
|
from loggers import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def get_colab_url(port: int = 8888) -> str:
|
|
"""
|
|
Get the actual Colab proxy URL for a port.
|
|
|
|
Retries up to 3 times and validates that the result is a real HTTPS Colab
|
|
URL before returning. Falls back to http://localhost:{port} only when all
|
|
attempts fail.
|
|
"""
|
|
import time as _time
|
|
|
|
fallback = f"http://localhost:{port}"
|
|
|
|
try:
|
|
from google.colab.output import eval_js
|
|
except ImportError:
|
|
return fallback
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
url = eval_js(f"google.colab.kernel.proxyPort({port})", timeout_sec = 10)
|
|
# A valid Colab proxy URL starts with https:// and embeds the port.
|
|
if (
|
|
url
|
|
and isinstance(url, str)
|
|
and url.startswith("https://")
|
|
and str(port) in url
|
|
):
|
|
return url.rstrip("/")
|
|
except Exception as e:
|
|
logger.info(f"Note: Could not get Colab URL (attempt {attempt + 1}/3: {e})")
|
|
if attempt < 2:
|
|
_time.sleep(1)
|
|
|
|
logger.warning(
|
|
f"Could not get a valid Colab proxy URL after 3 attempts — using localhost fallback. "
|
|
f"The link/iframe may not work from outside the runtime."
|
|
)
|
|
return fallback
|
|
|
|
|
|
def show_link(port: int = 8888, *, _url: "str | None" = None):
|
|
"""Display a styled clickable link to the UI.
|
|
|
|
*_url* is an optional pre-fetched Colab proxy URL. When omitted,
|
|
``get_colab_url(port)`` is called internally. Pass it from
|
|
``_show_and_embed`` to avoid a second ``eval_js`` round-trip.
|
|
"""
|
|
from IPython.display import display, HTML
|
|
|
|
url = _url if _url is not None else get_colab_url(port)
|
|
|
|
# Build a truncated display URL. Wrap in try/except so an unexpected URL
|
|
# shape never prevents the link from rendering.
|
|
try:
|
|
port_prefix = f"{port}-"
|
|
idx = url.index(port_prefix)
|
|
next_dash = url.index("-", idx + len(port_prefix))
|
|
short_url = url[: next_dash + 1] + "..."
|
|
except (ValueError, IndexError):
|
|
short_url = url
|
|
|
|
# Also emit a plain-text line so the URL is visible even if HTML display
|
|
# is suppressed or fails.
|
|
logger.info(f"🌐 Unsloth Studio URL: {url}")
|
|
|
|
html = f"""
|
|
<div style="display: inline-block; padding: 20px; background: #ffffff; border: 2px solid #000000;
|
|
border-radius: 12px; margin: 10px 0; font-family: system-ui, -apple-system, sans-serif;">
|
|
<h2 style="color: #000000; margin: 0 0 12px 0; font-size: 26px; font-weight: 800;
|
|
display: flex; align-items: center; gap: 12px;">
|
|
<img src="https://github.com/unslothai/unsloth/raw/main/studio/frontend/public/unsloth-gem.png"
|
|
height="48" style="display:block;">
|
|
Unsloth Studio is Ready!
|
|
</h2>
|
|
<a href="{url}" onclick="var w=window.open(this.href,'_blank');if(!w){{return true;}}return false;"
|
|
style="display: inline-flex; align-items: center; gap: 10px; padding: 14px 28px;
|
|
background: #000000; color: white; text-decoration: none; border-radius: 8px;
|
|
font-weight: 800; font-size: 16px; cursor: pointer;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="white"><polygon points="5,3 19,12 5,21"/></svg>
|
|
Open Unsloth Studio
|
|
</a>
|
|
<p style="color: #333333; margin: 12px 0 0 0; font-size: 14px; font-weight: bold;">
|
|
If the link doesn't work, you can scroll down to view the UI generated directly in Colab.
|
|
</p>
|
|
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace; font-weight: bold;">
|
|
{short_url}
|
|
</p>
|
|
</div>
|
|
"""
|
|
display(HTML(html))
|
|
|
|
|
|
def _is_studio_healthy(port: int, timeout: float = 2.0) -> bool:
|
|
"""Return True if a Studio backend is already answering health checks on *port*."""
|
|
import urllib.request
|
|
|
|
try:
|
|
with urllib.request.urlopen(
|
|
f"http://localhost:{port}/api/health", timeout = timeout
|
|
):
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _show_and_embed(port: int):
|
|
"""Embed the Studio inline for *port* with a branded header bar.
|
|
|
|
Fetches the Colab proxy URL once (registering the port with Colab's
|
|
reverse-proxy at the same time) then renders a header bar + full-height
|
|
iframe as a single HTML block.
|
|
|
|
Falls back to ``serve_kernel_port_as_iframe`` if ``IPython.display.HTML``
|
|
is unavailable for any reason.
|
|
"""
|
|
url = get_colab_url(port)
|
|
logger.info(f"🌐 Unsloth Studio URL: {url}")
|
|
|
|
try:
|
|
from IPython.display import HTML, display
|
|
|
|
iframe_id = f"unsloth-studio-{port}"
|
|
|
|
# Truncated URL shown in the header — best-effort, falls back to full URL.
|
|
try:
|
|
port_prefix = f"{port}-"
|
|
idx = url.index(port_prefix)
|
|
next_dash = url.index("-", idx + len(port_prefix))
|
|
short_url = url[: next_dash + 1] + "..."
|
|
except (ValueError, IndexError):
|
|
short_url = url
|
|
|
|
display(
|
|
HTML(f"""
|
|
<div style="font-family:system-ui,-apple-system,sans-serif;margin:8px 0;
|
|
border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.18);">
|
|
<div style="display:flex;align-items:center;gap:10px;padding:10px 16px;background:#000;">
|
|
<img src="https://github.com/unslothai/unsloth/raw/main/studio/frontend/public/unsloth-gem.png"
|
|
height="26" style="display:block;">
|
|
<span style="color:#fff;font-weight:700;font-size:15px;letter-spacing:-0.2px;">Unsloth Studio</span>
|
|
<span style="margin-left:auto;color:#666;font-size:11px;font-family:monospace;">{short_url}</span>
|
|
</div>
|
|
<iframe
|
|
id="{iframe_id}"
|
|
src="{url}"
|
|
style="width:100%;height:82vh;min-height:600px;max-height:1100px;border:none;display:block;box-sizing:border-box;"
|
|
allow="clipboard-read; clipboard-write"
|
|
></iframe>
|
|
</div>
|
|
""")
|
|
)
|
|
except Exception:
|
|
# Fallback: Colab's built-in helper (less control, but always works)
|
|
try:
|
|
from google.colab import output as colab_output
|
|
|
|
colab_output.serve_kernel_port_as_iframe(port, height = 900, width = "100%")
|
|
except ImportError:
|
|
pass
|
|
|
|
|
|
def start(port: int = 8888):
|
|
"""
|
|
Start Unsloth Studio server in Colab and display the URL.
|
|
|
|
Usage:
|
|
from colab import start
|
|
start()
|
|
"""
|
|
import time
|
|
|
|
logger.info("🦥 Starting Unsloth Studio...")
|
|
|
|
# --- Fast path: Studio is already running (cell re-run) ---
|
|
# Re-launching would either collide on the port or silently shift to a new
|
|
# port and confuse the user. Just re-show the link and iframe instead.
|
|
if _is_studio_healthy(port):
|
|
logger.info(
|
|
f" Studio is already running on port {port} — reusing existing server."
|
|
)
|
|
_show_and_embed(port)
|
|
try:
|
|
for _ in range(10000):
|
|
time.sleep(300)
|
|
print("=", end = "", flush = True)
|
|
except KeyboardInterrupt:
|
|
logger.info("\nUnsloth Studio keepalive stopped.")
|
|
return
|
|
|
|
logger.info(" Loading backend...")
|
|
from run import run_server
|
|
|
|
# Auto-detect frontend path
|
|
repo_root = Path(__file__).parent.parent
|
|
frontend_path = repo_root / "frontend" / "dist"
|
|
|
|
if not (frontend_path / "index.html").exists():
|
|
logger.info("❌ Frontend not built! Please run the setup cell first.")
|
|
return
|
|
|
|
logger.info(" Starting server...")
|
|
try:
|
|
app = run_server(
|
|
host = "0.0.0.0", port = port, frontend_path = frontend_path, silent = True
|
|
)
|
|
except SystemExit as exc:
|
|
logger.error(f"❌ Unsloth Studio failed to start: {exc}")
|
|
return
|
|
except Exception as exc:
|
|
logger.error(f"❌ Unsloth Studio failed to start: {exc}")
|
|
return
|
|
|
|
# run_server auto-increments the port when the requested one is already in
|
|
# use (e.g. Jupyter occupying 8888). Read back the actual bound port so the
|
|
# Colab proxy URL and iframe always point at the right place.
|
|
actual_port: int = getattr(getattr(app, "state", None), "server_port", None) or port
|
|
|
|
logger.info(f" Server started on port {actual_port}!")
|
|
|
|
# Poll health endpoint to confirm the server is truly reachable before
|
|
# showing the link and registering the iframe — avoids the race where
|
|
# ready_event fires but the process hasn't finished binding.
|
|
import urllib.request
|
|
|
|
server_ready = False
|
|
for _ in range(40):
|
|
try:
|
|
with urllib.request.urlopen(
|
|
f"http://localhost:{actual_port}/api/health", timeout = 1
|
|
):
|
|
server_ready = True
|
|
break
|
|
except Exception:
|
|
time.sleep(0.5)
|
|
|
|
if not server_ready:
|
|
logger.error(
|
|
f"❌ Unsloth Studio did not become healthy on port {actual_port}. "
|
|
"Check for errors above."
|
|
)
|
|
return
|
|
|
|
_show_and_embed(actual_port)
|
|
|
|
# Keep kernel alive so the daemon server thread stays running.
|
|
# Handle KeyboardInterrupt cleanly so the user gets a readable message
|
|
# rather than a raw traceback when they interrupt the cell.
|
|
try:
|
|
for _ in range(10000):
|
|
time.sleep(300)
|
|
print("=", end = "", flush = True)
|
|
except KeyboardInterrupt:
|
|
logger.info("\nUnsloth Studio keepalive stopped.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
start()
|