Fix terminal session: better error handling, reliable state save

- Fix asyncio.get_event_loop() → get_running_loop() in PTY reader
- Add error logging for PTY spawn failures
- Add POST /api/session/state endpoint for sendBeacon (beforeunload)
- Use navigator.sendBeacon for reliable state save on page close
- Improve frontend error reporting when terminal creation fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-14 21:46:08 -05:00
parent 220a5234cd
commit 68fe9b4435
4 changed files with 57 additions and 13 deletions

View file

@ -2,10 +2,10 @@
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request, Query
from pydantic import BaseModel from pydantic import BaseModel
from backend.auth import get_current_user from backend.auth import get_current_user, decode_token
from backend.sessions import manager from backend.sessions import manager
router = APIRouter(prefix="/api/session", tags=["session"]) router = APIRouter(prefix="/api/session", tags=["session"])
@ -41,6 +41,8 @@ async def get_session(user: str = Depends(get_current_user)):
@router.put("/state") @router.put("/state")
async def save_state( async def save_state(
state: DesktopState, state: DesktopState,
request: Request,
token: str = Query(default=None),
user: str = Depends(get_current_user), user: str = Depends(get_current_user),
): ):
"""Save desktop state (open apps, active app, terminal tab info).""" """Save desktop state (open apps, active app, terminal tab info)."""
@ -48,6 +50,25 @@ async def save_state(
return {"ok": True} return {"ok": True}
@router.post("/state")
async def save_state_beacon(
request: Request,
token: str = Query(default=None),
):
"""Save desktop state via sendBeacon (POST with token in query param)."""
if not token:
raise HTTPException(401, "Missing token")
try:
payload = decode_token(token)
except Exception:
raise HTTPException(401, "Invalid token")
user = payload["sub"]
body = await request.json()
state = DesktopState(**body)
manager.save_state(user, state.model_dump())
return {"ok": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Terminal endpoints # Terminal endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -64,7 +85,11 @@ async def create_terminal(
user: str = Depends(get_current_user), user: str = Depends(get_current_user),
): ):
"""Create a new persistent terminal.""" """Create a new persistent terminal."""
terminal = manager.create_terminal(user, cols=req.cols, rows=req.rows) try:
terminal = manager.create_terminal(user, cols=req.cols, rows=req.rows)
except Exception as e:
log.exception("Failed to create terminal for %s", user)
raise HTTPException(500, f"Failed to spawn terminal: {e}")
return terminal.to_dict() return terminal.to_dict()

View file

@ -64,7 +64,7 @@ class ManagedTerminal:
async def _read_loop(self): async def _read_loop(self):
"""Background task: read PTY output → scrollback + attached WebSockets.""" """Background task: read PTY output → scrollback + attached WebSockets."""
loop = asyncio.get_event_loop() loop = asyncio.get_running_loop()
while self.alive: while self.alive:
try: try:
raw = await loop.run_in_executor(None, lambda: self.pty.read(4096)) raw = await loop.run_in_executor(None, lambda: self.pty.read(4096))
@ -237,12 +237,18 @@ class SessionManager:
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
} }
pty = PtyProcess.spawn( log.info("Spawning PTY for %s: shell=%s home=%s", username, shell, home)
[shell, "-l"],
dimensions=(rows, cols), try:
env=env, pty = PtyProcess.spawn(
cwd=home, [shell, "-l"],
) dimensions=(rows, cols),
env=env,
cwd=home,
)
except Exception:
log.exception("Failed to spawn PTY for %s", username)
raise
terminal_id = str(uuid.uuid4())[:8] terminal_id = str(uuid.uuid4())[:8]
terminal = ManagedTerminal( terminal = ManagedTerminal(

View file

@ -19,7 +19,11 @@
method: 'POST', method: 'POST',
body: { cols: 120, rows: 30 }, body: { cols: 120, rows: 30 },
}); });
if (!res.ok) return null; if (!res || !res.ok) {
const err = res ? await res.text().catch(() => 'unknown') : 'no response';
console.error('Failed to create terminal:', err);
return null;
}
const data = await res.json(); const data = await res.json();
return attachToTerminal(data.terminal_id, data.title); return attachToTerminal(data.terminal_id, data.title);
} catch (e) { } catch (e) {

View file

@ -522,9 +522,18 @@
} }
}); });
// Save state before unload // Save state before unload (use sendBeacon for reliability)
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
_doSaveState(); const termApp = Atlus.apps.terminal;
const terminalTabs = (termApp && termApp.getTerminalIds)
? termApp.getTerminalIds() : [];
const state = {
open_apps: Atlus.openApps.slice(),
active_app: Atlus.activeApp,
terminal_tabs: terminalTabs,
};
const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
navigator.sendBeacon('/api/session/state?token=' + TOKEN, blob);
}); });
// ===================================================================== // =====================================================================