linnet/app/main.py
pyr0ball 1bc47b8e0f fix: mobile tab timeout — idle session reaper + wake lock + visibility reconnect
Backend:
- Session.last_subscriber_left_at: monotonic timestamp set when last SSE subscriber
  leaves, cleared when a new one arrives
- Session.subscriber_count(): replaces len(_subscribers) access from outside the model
- session_store._reaper_loop(): kills sessions with no subscribers for >SESSION_IDLE_TTL_S
  (default 90s); runs every TTL/2 seconds via asyncio.create_task at startup
- session_store._reaper_loop_once(): single-cycle variant for deterministic tests
- app/main.py lifespan: starts reaper on startup, cancels it cleanly on shutdown
- config.py: SESSION_IDLE_TTL_S setting (90s default, overridable per-env)

Frontend:
- useWakeLock.ts: Screen Wake Lock API wrapper; acquires on connect, releases on
  disconnect; degrades silently when unsupported (battery saver, iOS Safari)
- useToneStream.ts: visibilitychange handler — on hidden: closes EventSource without
  ending backend session (grace window stays open); on visible: GET /session/{id}
  liveness check, reconnects SSE + re-acquires wake lock if alive, sets expired=true
  and calls store.reset() if reaped
- ComposeBar.vue: surfaces expired state with calm 'Session timed out' notice
  (not an error — expected behaviour on long screen-off)

Tests:
- test_reaper.py: 7 tests covering subscriber idle tracking, reaper eligibility
  (kills idle, spares active subscriber, spares within-TTL)
2026-04-11 09:42:42 -07:00

81 lines
2.7 KiB
Python

# app/main.py — Linnet FastAPI application factory
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import audio, corrections, events, export, history, samples, sessions
from app.config import settings
from app.services import session_store
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s%(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: begin idle session reaper
session_store.start_reaper()
yield
# Shutdown: cancel the reaper cleanly
await session_store.stop_reaper()
app = FastAPI(
title="Linnet",
description="Real-time tone annotation — tonal subtext labels for ND/autistic users",
version="0.1.0",
lifespan=lifespan,
)
# ── Mode middleware (applied before CORS so headers are always present) ──────
if settings.demo_mode:
from app.middleware.demo import DemoModeMiddleware
app.add_middleware(DemoModeMiddleware)
logging.getLogger(__name__).info("DEMO_MODE active")
if settings.cloud_mode:
from app.middleware.cloud import CloudAuthMiddleware
app.add_middleware(CloudAuthMiddleware)
logging.getLogger(__name__).info("CLOUD_MODE active")
# ── CORS ─────────────────────────────────────────────────────────────────────
_frontend_port = str(settings.linnet_frontend_port)
_origins = [
f"http://localhost:{_frontend_port}",
f"http://127.0.0.1:{_frontend_port}",
]
if settings.cloud_mode:
_origins += [
"https://menagerie.circuitforge.tech",
"https://circuitforge.tech",
]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(sessions.router)
app.include_router(events.router)
app.include_router(history.router)
app.include_router(audio.router)
app.include_router(export.router)
app.include_router(samples.router)
app.include_router(corrections.router, prefix="/corrections", tags=["corrections"])
@app.get("/health")
def health() -> dict:
mode = "demo" if settings.demo_mode else ("cloud" if settings.cloud_mode else "dev")
return {"status": "ok", "service": "linnet", "mode": mode}