fix: support local storage runtime and schedule logs

This commit is contained in:
Winnie
2026-08-14 15:06:36 +08:00
parent 65d14b8610
commit be475dd0d9
4 changed files with 64 additions and 11 deletions
+6 -2
View File
@@ -7,6 +7,7 @@ surface is two endpoints; everything else is lifespan orchestration.
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from typing import Literal
@@ -48,7 +49,10 @@ async def lifespan(app: FastAPI):
f"removed_meta={counters['removed_meta']} "
f"live_in_registry={counters['live_in_registry']}"
)
await scan_workspaces()
# Scanning a remote FUSE mount may take longer than the HTTP health-check
# timeout. Keep recovery asynchronous so dependent services can start as
# soon as the runtime API is ready.
asyncio.create_task(scan_workspaces(), name="workspace-startup-scan")
# P1-3: spawn background idle reaper. Stopped in the lifespan
# finally block; awaits the cancellation to avoid a leaked task.
start_reaper()
@@ -112,4 +116,4 @@ async def handle_jupyter_action(req: JupyterActionRequest) -> dict:
status_code=400,
detail="'workspace_id' is required for action='get'",
)
return await get_workspace(req.workspace_id)
return await get_workspace(req.workspace_id)
+43
View File
@@ -37,6 +37,7 @@ import asyncio
import json
import os
import secrets
import socket
import subprocess
import time
from typing import TypedDict
@@ -163,6 +164,37 @@ def _bump_last_used(record: JupyterProcessRecord) -> None:
record["last_used_at"] = time.time()
async def _wait_for_jupyter_ready(
process: subprocess.Popen,
port: int,
*,
timeout_seconds: float = 30,
) -> None:
"""Wait until a newly spawned Jupyter server accepts TCP connections.
Starting the subprocess only means that Python has been forked. Jupyter
still needs several seconds to load extensions and bind its port. The
runtime API must not advertise the workspace as ``running`` before that
happens, otherwise callers immediately receive a transient connection
failure while creating or opening the first notebook.
"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(
f"Jupyter exited during startup (exit code {process.returncode})"
)
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
return
except OSError:
await asyncio.sleep(0.2)
raise RuntimeError(
f"Timed out after {timeout_seconds:g}s waiting for Jupyter on port {port}"
)
async def start_workspace(ws_id: str) -> dict:
async with get_workspace_lock(ws_id):
workspace_path = (WORKSPACES_ROOT / ws_id).resolve()
@@ -297,6 +329,17 @@ async def start_workspace(ws_id: str) -> dict:
detail=f"Failed to start Jupyter: {e}",
)
try:
await _wait_for_jupyter_ready(process, port)
except Exception as exc:
logger.exception(f"Jupyter did not become ready for workspace={ws_id}")
if process.poll() is None:
process.terminate()
raise HTTPException(
status_code=503,
detail=f"Jupyter failed to start: {exc}",
) from exc
# ---------------------------------------------------------
# Save process metadata
# ---------------------------------------------------------