Round 41 review findings (2 P1, 5/12 reviewers consensus on the dominant one):
1. routes/export.py: load_checkpoint already refuses 409 when training
or another export is active, but /export/{merged,base,gguf,lora} and
/cleanup went through _export_public_window without those checks.
A user could start training, then trigger an export (or cleanup),
and both would double-own the GPU. Factor the training-active and
export-active guards into _raise_if_training_active_for_export and
_raise_if_export_active_for_export, call them inside the context
manager so all /export/* + /cleanup share the same fail-closed
semantics as load_checkpoint, and wrap /cleanup with the window.
2. core/inference/diffusion.py: DiffusionBackend.unload_model cleared
_pipe / _repo_id / _family / ... under _lock BEFORE _release(old)
and _drain_cuda_cache. Between the lock release and cache drain,
status() reported is_loaded=False / is_loading=False, so the
helper-busy check (which OR-s those two) could let an AI Assist
GGUF backend start while diffusion VRAM was still being freed.
Set _loading=True inside the lock as a busy marker before clearing
the slot, and only clear it in a finally after release + drain
complete.
706 lines
26 KiB
Python
706 lines
26 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
|
|
|
|
"""
|
|
Export API routes: checkpoint discovery and model export operations.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import StreamingResponse
|
|
import structlog
|
|
from loggers import get_logger
|
|
|
|
# Add backend directory to path
|
|
backend_path = Path(__file__).parent.parent.parent
|
|
if str(backend_path) not in sys.path:
|
|
sys.path.insert(0, str(backend_path))
|
|
|
|
# Auth
|
|
from auth.authentication import get_current_subject
|
|
|
|
# Import backend functions
|
|
try:
|
|
from core.export import get_export_backend
|
|
except ImportError:
|
|
parent_backend = backend_path.parent / "backend"
|
|
if str(parent_backend) not in sys.path:
|
|
sys.path.insert(0, str(parent_backend))
|
|
from core.export import get_export_backend
|
|
|
|
# Import Pydantic models
|
|
from models import (
|
|
LoadCheckpointRequest,
|
|
ExportStatusResponse,
|
|
ExportOperationResponse,
|
|
ExportMergedModelRequest,
|
|
ExportBaseModelRequest,
|
|
ExportGGUFRequest,
|
|
ExportLoRAAdapterRequest,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
import contextlib
|
|
|
|
|
|
def _raise_if_training_active_for_export() -> None:
|
|
"""409 if a training run is in flight; 503 if status check itself
|
|
raises. Mirrors the load_checkpoint guard so /export/* and /cleanup
|
|
never tear down or alter export state while training is using the
|
|
GPU. Missing core.training is treated as 'no tracker'."""
|
|
try:
|
|
from core.training import get_training_backend # type: ignore
|
|
except Exception as e:
|
|
logger.debug("core.training not importable, skipping training guard: %s", e)
|
|
return
|
|
try:
|
|
trn = get_training_backend()
|
|
active = trn.is_training_active()
|
|
except Exception as e:
|
|
logger.warning("Could not verify training status before export op: %s", e)
|
|
raise HTTPException(
|
|
status_code = 503,
|
|
detail = (
|
|
"Could not verify training status before the export "
|
|
"operation. Try again."
|
|
),
|
|
) from e
|
|
if active:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
"Training is currently active. Stop the training run "
|
|
"before starting an export operation."
|
|
),
|
|
)
|
|
|
|
|
|
def _raise_if_export_active_for_export() -> None:
|
|
"""409 if another export job is already running; 503 if the status
|
|
check itself raises. Backends without is_export_active() are
|
|
treated as 'no tracker available' to stay compatible with mocked
|
|
backends in tests."""
|
|
backend = get_export_backend()
|
|
is_export_active_fn = getattr(backend, "is_export_active", None)
|
|
if is_export_active_fn is None:
|
|
return
|
|
try:
|
|
export_is_active = bool(is_export_active_fn())
|
|
except Exception as e:
|
|
logger.warning("Could not verify export status before export op: %s", e)
|
|
raise HTTPException(
|
|
status_code = 503,
|
|
detail = (
|
|
"Could not verify export status before starting the "
|
|
"export operation. Try again."
|
|
),
|
|
) from e
|
|
if export_is_active:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
"An export job is currently active. Wait for it to "
|
|
"finish before starting another export operation."
|
|
),
|
|
)
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _export_public_window():
|
|
"""Publish the public-load window across an /export/* operation.
|
|
|
|
backend.export_*() runs in a worker thread and does not flip
|
|
``_export_active = True`` until the worker actually starts; during
|
|
that gap window another workload that calls ``_release_export_for``
|
|
would see ``is_export_active() == False`` and tear down the export
|
|
subprocess. Mirror the load_checkpoint guard so the pending counter
|
|
is set for the whole export call, and the helper-busy preflight
|
|
refuses if AI Assist is mid-handoff.
|
|
|
|
Also refuses 409 if training or another export is already active so
|
|
a queued /export/{merged,base,gguf,lora} or /cleanup cannot
|
|
double-own the GPU with a running training / export job (round 41
|
|
consensus: load_checkpoint already runs these checks but /export/*
|
|
and /cleanup were skipping them).
|
|
"""
|
|
from routes.inference import (
|
|
_clear_public_load_window,
|
|
_raise_if_helper_advisor_busy,
|
|
)
|
|
|
|
export_window_published = False
|
|
try:
|
|
_raise_if_training_active_for_export()
|
|
_raise_if_export_active_for_export()
|
|
_raise_if_helper_advisor_busy("export")
|
|
export_window_published = True
|
|
yield
|
|
finally:
|
|
if export_window_published:
|
|
try:
|
|
_clear_public_load_window("export")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.post("/load-checkpoint", response_model = ExportOperationResponse)
|
|
async def load_checkpoint(
|
|
request: LoadCheckpointRequest,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Load a checkpoint into the export backend.
|
|
|
|
Wraps ExportBackend.load_checkpoint.
|
|
"""
|
|
# Round 30 P1 #8: track whether we published a public-load pending
|
|
# entry so the outer finally clears it on either success or
|
|
# failure path.
|
|
export_load_window_published = False
|
|
try:
|
|
# Version switching is handled automatically by the subprocess-based
|
|
# export backend — no need for ensure_transformers_version() here.
|
|
|
|
# Symmetric lifecycle guard: refuse to load an export
|
|
# checkpoint while training is active so we do not silently
|
|
# terminate someone's long-running training job and possibly
|
|
# fail the export load on top of that. Mirrors the
|
|
# _raise_if_training_active checks in routes/inference.py for
|
|
# chat and /images/load.
|
|
# Run BEFORE the chat / inference / diffusion unload helpers
|
|
# below: otherwise a 409 from this guard would still leave
|
|
# the user's chat / inference / diffusion GPU owners freed
|
|
# for nothing, which is the asymmetry round 7 review #5
|
|
# flagged. Fail-CLOSED (503) when the training backend is
|
|
# importable but its status check raises.
|
|
try:
|
|
from core.training import get_training_backend # type: ignore
|
|
except Exception as e:
|
|
logger.debug(
|
|
"core.training not importable, skipping export training guard: %s",
|
|
e,
|
|
)
|
|
else:
|
|
try:
|
|
trn = get_training_backend()
|
|
active = trn.is_training_active()
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Could not verify training status before export load: %s", e
|
|
)
|
|
raise HTTPException(
|
|
status_code = 503,
|
|
detail = (
|
|
"Could not verify training status before loading "
|
|
"an export checkpoint. Try again."
|
|
),
|
|
) from e
|
|
if active:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
"Training is currently active. Stop the training "
|
|
"run before loading an export checkpoint."
|
|
),
|
|
)
|
|
|
|
backend = get_export_backend()
|
|
# Refuse to reload the export checkpoint while an export job
|
|
# is still running. ``ExportBackend.load_checkpoint`` would
|
|
# terminate the running subprocess in order to spawn a new
|
|
# one, silently corrupting the partial output the user is
|
|
# waiting on (round 13 P1 #1). Runs BEFORE the chat /
|
|
# diffusion unloads below: a 409 from this guard must not
|
|
# leave the user's chat or diffusion GPU owners freed for
|
|
# nothing (round 14 P1 #1). ``is_export_active`` may be
|
|
# absent on older / mocked backends; treat missing as "no
|
|
# async-job tracker available" and skip rather than
|
|
# fail-closed.
|
|
is_export_active_fn = getattr(backend, "is_export_active", None)
|
|
if is_export_active_fn is not None:
|
|
try:
|
|
export_is_active = bool(is_export_active_fn())
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Could not verify export status before export load: %s", e
|
|
)
|
|
raise HTTPException(
|
|
status_code = 503,
|
|
detail = (
|
|
"Could not verify export status before loading "
|
|
"an export checkpoint. Try again."
|
|
),
|
|
) from e
|
|
if export_is_active:
|
|
raise HTTPException(
|
|
status_code = 409,
|
|
detail = (
|
|
"An export job is currently active. Stop the "
|
|
"export job before loading another checkpoint."
|
|
),
|
|
)
|
|
|
|
# Free GPU memory: shut down any chat backend before loading
|
|
# the export checkpoint. Routes the unload through the shared
|
|
# helper so we cover llama-server is_active=True and
|
|
# safetensors loading_models -- the asymmetries round 9
|
|
# reviews #1, #8, #9 flagged.
|
|
from routes.inference import (
|
|
_clear_public_load_window,
|
|
_raise_if_helper_advisor_busy,
|
|
_release_chat_for,
|
|
_release_diffusion_for,
|
|
)
|
|
|
|
# Round 28 P1 #6: refuse before any release fires so AI Assist
|
|
# busy does not first tear down idle diffusion.
|
|
# Round 30 P1 #8: also publishes a public-load pending entry so
|
|
# a concurrent helper / advisor start cannot win the start
|
|
# lock between our snapshot and load_checkpoint flipping
|
|
# current_checkpoint / is_export_active.
|
|
_raise_if_helper_advisor_busy("export")
|
|
export_load_window_published = True
|
|
# Round 24 P1 #3: release diffusion BEFORE chat so a failing
|
|
# diffusion unload does not leave the user with no chat
|
|
# model loaded. Same reasoning as the training-start flow
|
|
# (round 18 P1 #8 / round 24 P1 #2). Earlier rounds kept the
|
|
# chat release first because the helper was best-effort;
|
|
# now that ``_release_diffusion_for`` is strict it must run
|
|
# while chat is still resident so a failure preserves it.
|
|
await _release_diffusion_for("export load")
|
|
await _release_chat_for("export")
|
|
|
|
# load_checkpoint spawns and waits on a subprocess and can take
|
|
# minutes. Run it in a worker thread so the event loop stays
|
|
# free to serve the live log SSE stream concurrently.
|
|
success, message = await asyncio.to_thread(
|
|
backend.load_checkpoint,
|
|
checkpoint_path = request.checkpoint_path,
|
|
max_seq_length = request.max_seq_length,
|
|
load_in_4bit = request.load_in_4bit,
|
|
trust_remote_code = request.trust_remote_code,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(success = True, message = message)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error loading checkpoint: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to load checkpoint: {str(e)}",
|
|
)
|
|
finally:
|
|
# Round 30 P1 #8: clear the public-load pending entry once the
|
|
# load attempt completes (success or failure). Skipped when
|
|
# the helper-busy check itself raised so the counter stays in
|
|
# sync with publishes.
|
|
if export_load_window_published:
|
|
try:
|
|
from routes.inference import _clear_public_load_window
|
|
except Exception:
|
|
pass
|
|
else:
|
|
_clear_public_load_window("export")
|
|
|
|
|
|
@router.post("/cleanup", response_model = ExportOperationResponse)
|
|
async def cleanup_export_memory(
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Cleanup export-related models from memory (GPU/CPU).
|
|
|
|
Wraps ExportBackend.cleanup_memory.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
# Run the cleanup under the same public-load window /export/*
|
|
# uses so a queued export's handoff gap cannot race a cleanup
|
|
# call that tears down current_checkpoint. The window also
|
|
# refuses 409 if training or another export is in flight.
|
|
async with _export_public_window():
|
|
success = await asyncio.to_thread(backend.cleanup_memory)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Memory cleanup failed. See server logs for details.",
|
|
)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = "Memory cleanup completed successfully",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error during export memory cleanup: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to cleanup export memory: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/status", response_model = ExportStatusResponse)
|
|
async def get_export_status(
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Get current export backend status (loaded checkpoint, model type, PEFT flag).
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
return ExportStatusResponse(
|
|
current_checkpoint = backend.current_checkpoint,
|
|
is_vision = bool(getattr(backend, "is_vision", False)),
|
|
is_peft = bool(getattr(backend, "is_peft", False)),
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error getting export status: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to get export status: {str(e)}",
|
|
)
|
|
|
|
|
|
def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
"""Return the export path relative to exports_root so the install path is not leaked."""
|
|
if not output_path:
|
|
return None
|
|
try:
|
|
from utils.paths.storage_roots import exports_root
|
|
|
|
rel = os.path.relpath(output_path, exports_root())
|
|
if rel.startswith(".."):
|
|
rel = os.path.basename(output_path)
|
|
return {"output_path": rel}
|
|
except Exception:
|
|
return {"output_path": os.path.basename(output_path)}
|
|
|
|
|
|
@router.post("/export/merged", response_model = ExportOperationResponse)
|
|
async def export_merged_model(
|
|
request: ExportMergedModelRequest,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Export a merged PEFT model (e.g., 16-bit or 4-bit) and optionally push to Hub.
|
|
|
|
Wraps ExportBackend.export_merged_model.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
async with _export_public_window():
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_merged_model,
|
|
save_directory = request.save_directory,
|
|
format_type = request.format_type,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
private = request.private,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error exporting merged model: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to export merged model: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.post("/export/base", response_model = ExportOperationResponse)
|
|
async def export_base_model(
|
|
request: ExportBaseModelRequest,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Export a non-PEFT base model and optionally push to Hub.
|
|
|
|
Wraps ExportBackend.export_base_model.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
async with _export_public_window():
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_base_model,
|
|
save_directory = request.save_directory,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
private = request.private,
|
|
base_model_id = request.base_model_id,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error exporting base model: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to export base model: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.post("/export/gguf", response_model = ExportOperationResponse)
|
|
async def export_gguf(
|
|
request: ExportGGUFRequest,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Export the current model to GGUF format and optionally push to Hub.
|
|
|
|
Wraps ExportBackend.export_gguf.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
async with _export_public_window():
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_gguf,
|
|
save_directory = request.save_directory,
|
|
quantization_method = request.quantization_method,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error exporting GGUF model: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to export GGUF model: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.post("/export/lora", response_model = ExportOperationResponse)
|
|
async def export_lora_adapter(
|
|
request: ExportLoRAAdapterRequest,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Export only the LoRA adapter (if the loaded model is PEFT).
|
|
|
|
Wraps ExportBackend.export_lora_adapter.
|
|
"""
|
|
try:
|
|
backend = get_export_backend()
|
|
async with _export_public_window():
|
|
success, message, output_path = await asyncio.to_thread(
|
|
backend.export_lora_adapter,
|
|
save_directory = request.save_directory,
|
|
push_to_hub = request.push_to_hub,
|
|
repo_id = request.repo_id,
|
|
hf_token = request.hf_token,
|
|
private = request.private,
|
|
)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code = 400, detail = message)
|
|
|
|
return ExportOperationResponse(
|
|
success = True,
|
|
message = message,
|
|
details = _export_details(output_path),
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error exporting LoRA adapter: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to export LoRA adapter: {str(e)}",
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Live export log stream (Server-Sent Events)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
#
|
|
# The export worker subprocess redirects its stdout/stderr into a pipe
|
|
# that a reader thread forwards to the orchestrator as log entries (see
|
|
# core/export/worker.py::_setup_log_capture and
|
|
# core/export/orchestrator.py::_append_log). This endpoint streams
|
|
# those entries to the browser so the export dialog can show a live
|
|
# terminal-style output panel while load_checkpoint / export_merged /
|
|
# export_gguf / export_lora / export_base run.
|
|
#
|
|
# Shape follows the training progress SSE endpoint
|
|
# (routes/training.py::stream_training_progress): each event carries
|
|
# `id`, `event`, and `data` fields, the stream starts with a `retry:`
|
|
# directive, and `Last-Event-ID` is honored on reconnect.
|
|
|
|
|
|
def _format_sse(data: str, event: str, event_id: Optional[int] = None) -> str:
|
|
"""Format a single SSE message with id/event/data fields."""
|
|
lines = []
|
|
if event_id is not None:
|
|
lines.append(f"id: {event_id}")
|
|
lines.append(f"event: {event}")
|
|
lines.append(f"data: {data}")
|
|
lines.append("")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
@router.get("/logs/stream")
|
|
async def stream_export_logs(
|
|
request: Request,
|
|
since: Optional[int] = Query(
|
|
None,
|
|
description = "Return log entries with seq strictly greater than this cursor.",
|
|
),
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
"""
|
|
Stream live stdout/stderr output from the export worker subprocess
|
|
as Server-Sent Events.
|
|
|
|
Events:
|
|
- `log` : a single log line (data: {"stream","line","ts"})
|
|
- `heartbeat`: periodic keepalive when no new lines are available
|
|
- `complete` : emitted once the export worker is idle and no new
|
|
lines arrived for ~1 second. Clients should close.
|
|
- `error` : unrecoverable server-side error
|
|
|
|
The `id:` field on each event is the log entry's monotonic seq
|
|
number so the browser can resume via `Last-Event-ID` on reconnect.
|
|
"""
|
|
backend = get_export_backend()
|
|
|
|
# Determine starting cursor. Explicit `since` wins, then
|
|
# Last-Event-ID header on reconnect, otherwise start from the
|
|
# run-start snapshot captured by clear_logs() so the client sees
|
|
# every line emitted since the current run began -- even if the
|
|
# SSE connection opened after the POST that kicked off the export.
|
|
# Using get_current_log_seq() here would lose the early bootstrap
|
|
# lines that arrive in the gap between POST and SSE connect.
|
|
last_event_id = request.headers.get("last-event-id")
|
|
if since is None and last_event_id is not None:
|
|
try:
|
|
since = int(last_event_id)
|
|
except ValueError:
|
|
pass
|
|
|
|
if since is None:
|
|
cursor = backend.get_run_start_seq()
|
|
else:
|
|
cursor = max(0, int(since))
|
|
|
|
async def event_generator() -> AsyncGenerator[str, None]:
|
|
nonlocal cursor
|
|
# Tell the browser to reconnect after 3 seconds if the
|
|
# connection drops mid-export.
|
|
yield "retry: 3000\n\n"
|
|
|
|
last_yield = time.monotonic()
|
|
idle_since: Optional[float] = None
|
|
try:
|
|
while True:
|
|
if await request.is_disconnected():
|
|
return
|
|
|
|
entries, new_cursor = backend.get_logs_since(cursor)
|
|
if entries:
|
|
for entry in entries:
|
|
payload = json.dumps(
|
|
{
|
|
"stream": entry.get("stream", "stdout"),
|
|
"line": entry.get("line", ""),
|
|
"ts": entry.get("ts"),
|
|
}
|
|
)
|
|
yield _format_sse(
|
|
payload,
|
|
event = "log",
|
|
event_id = int(entry.get("seq", 0)),
|
|
)
|
|
cursor = new_cursor
|
|
last_yield = time.monotonic()
|
|
idle_since = None
|
|
else:
|
|
now = time.monotonic()
|
|
if now - last_yield > 10.0:
|
|
yield _format_sse("{}", event = "heartbeat")
|
|
last_yield = now
|
|
if not backend.is_export_active():
|
|
# Give the reader thread a moment to drain any
|
|
# trailing lines the worker process printed
|
|
# just before signalling done.
|
|
if idle_since is None:
|
|
idle_since = now
|
|
elif now - idle_since > 1.0:
|
|
yield _format_sse(
|
|
"{}",
|
|
event = "complete",
|
|
event_id = cursor,
|
|
)
|
|
return
|
|
else:
|
|
idle_since = None
|
|
|
|
await asyncio.sleep(0.1)
|
|
except asyncio.CancelledError:
|
|
# Client disconnected mid-yield. Don't re-raise, just end
|
|
# the generator cleanly so StreamingResponse finalizes.
|
|
return
|
|
except Exception as exc:
|
|
logger.error("Export log stream failed: %s", exc, exc_info = True)
|
|
try:
|
|
yield _format_sse(
|
|
json.dumps({"error": str(exc)}),
|
|
event = "error",
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return StreamingResponse(
|
|
event_generator(),
|
|
media_type = "text/event-stream",
|
|
headers = {
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|