fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself

The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
This commit is contained in:
2026-09-20 00:09:52 +02:00
parent 9cf177b198
commit 134ec9a68e
25 changed files with 1007 additions and 208 deletions
+64 -19
View File
@@ -11,7 +11,6 @@ those types, whatever the Content-Type claimed.
"""
import json
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@@ -42,8 +41,11 @@ class StartIn(BaseModel):
level: debugsession.SessionLevel
def _view(db: Session, session: DebugSession) -> dict:
return debugsession.serialize(session, debugsession.screenshot_count(db, session)) or {}
_view = debugsession.view
# New sessions a user may open per hour (joining the running one is not counted): a session is a
# place to keep screenshots, and this — with the per-user storage cap — bounds how many there are.
_new_sessions = RateLimiter(max_events=20, window_seconds=3600)
@router.post("")
@@ -53,6 +55,9 @@ def start_session(
db: Session = Depends(get_db),
) -> dict:
"""Start recording, or join the recording already running (another tab started it)."""
joining = debugsession.open_session(db, user) is not None
if not joining and not _new_sessions.allow(str(user.id)):
raise refused(429, "debug_session_rate_limited", limit=_new_sessions.max_events)
try:
session = debugsession.start(db, user, body.level)
db.commit()
@@ -81,11 +86,9 @@ def stop_session(
# Logged BEFORE the session closes, while this request still carries its tag: the last line
# of the evidence is the Stop itself.
log.info("debug session stopped", extra=logs.event("debug.session.stopped"))
# Past its cap it had already ended on its own; a late Stop must not rename that.
live = session.until > datetime.now(timezone.utc)
reason = debugsession.EndReason.STOPPED if live else debugsession.EndReason.EXPIRED
debugsession.end(session, reason)
debugsession.stop(session)
db.commit()
logs.end_session_elevation(session.id)
return {"ok": True, "session": _view(db, session)}
@@ -121,6 +124,8 @@ def session_events(
# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
# was — bounded here so a draft cannot become a place to store anything else.
FORM_MAX_BYTES = 64 * 1024
# Zips a user may build per minute: each holds every screenshot in memory while it is built.
_exports = RateLimiter(max_events=6, window_seconds=60)
class DraftIn(BaseModel):
@@ -140,11 +145,14 @@ def save_draft(
db: Session = Depends(get_db),
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
# Under the lock: a save that arrives while the issue is being sent waits for the send, and then
# sees it — the draft of a sent issue cannot change after it.
debugsession.lock(db, session)
if session.issue_url is not None:
# Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
raise refused(409, "issue_already_sent")
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
debugsession.save_draft(session, body.model_dump())
db.commit()
return {"ok": True, "session": _view(db, session)}
@@ -158,6 +166,8 @@ def export_session(
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
what it saved."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if not _exports.allow(str(user.id)):
raise refused(429, "export_rate_limited")
data = bundle.zipped(bundle.files(db, session))
name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
return Response(
@@ -171,11 +181,16 @@ def export_session(
# --- the issue tracker (S4) -----------------------------------------------------------------
# Issues a user may OPEN per hour — finishing a half-sent one is not counted. The composer sends one
# at a time; this is room for a bad afternoon, not a pace anything legitimate keeps up.
# Issues a user may OPEN per hour — finishing a half-sent one, or an attempt refused before the issue
# was opened, is not counted. The composer sends one at a time; this is room for a bad afternoon, not
# a pace anything legitimate keeps up.
_new_issues = RateLimiter(max_events=5, window_seconds=3600)
def _issues_rate_limited() -> HTTPException:
return refused(429, "issues_rate_limited", limit=_new_issues.max_events)
@router.post("/{sid}/issue")
def send_issue(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
@@ -183,22 +198,40 @@ def send_issue(
"""Send the session's issue to the tracker — from the SAVED draft, as the export is built — or
finish a send that stopped half-way (the issue exists; only what is missing is sent)."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
# Held to the end: a second send (another tab, a retry after a timeout) waits for this one and
# then finds the issue it opened, instead of opening another; an autosave or a screenshot waits
# too, and then finds the issue sent.
debugsession.lock(db, session)
new = session.issue_url is None
if not new and not gitea.pending(session):
if not new and not debugsession.unfinished(session):
return {"ok": True, "session": _view(db, session)}
key = str(user.id)
if new:
if not ((session.draft or {}).get("title") or "").strip():
raise refused(422, "issue_title_required")
if not _new_issues.allow(str(user.id)):
raise refused(429, "issues_rate_limited")
if _new_issues.exhausted(key):
raise _issues_rate_limited()
def count_new_issue() -> None:
if not _new_issues.allow(key):
raise _issues_rate_limited()
try:
gitea.deliver(db, session)
gitea.deliver(db, session, before_create=count_new_issue)
except gitea.GiteaError as exc:
# Keep what did happen: an issue created before an upload failed stays linked, and the
# retry sends only the files still missing.
# retry does only what is still missing.
_record_created(db, user, session, new)
db.commit()
raise issue_refused(exc) from None
except HTTPException:
raise # the pace, refused before anything was opened
except Exception:
# Anything else after the issue was opened must not lose the record that it was: a retry
# would open a second one.
_record_created(db, user, session, new)
db.commit()
raise
_record_created(db, user, session, new)
db.commit()
return {"ok": True, "session": _view(db, session)}
@@ -282,13 +315,21 @@ def _screenshot_refused(code: str) -> HTTPException:
return refused(409, "screenshot_already_sent")
if code == "too_many":
return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX)
if code == "storage_full":
mb = debugsession.SCREENSHOT_USER_MAX_BYTES // (1024 * 1024)
return refused(422, "screenshot_storage_full", limit=mb)
if code == "empty":
return refused(422, "screenshot_empty")
return refused(422, "screenshot_not_an_image")
def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
return _screenshot_of(
db, owned_or_404(db, DebugSession, sid, user, what="debug_session"), shot_id
)
def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScreenshot:
shot = db.get(DebugScreenshot, shot_id)
if shot is None or shot.session_id != session.id:
raise not_found("screenshot")
@@ -312,7 +353,11 @@ def get_screenshot(
def delete_screenshot(
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
shot = _session_screenshot(db, sid, shot_id, user)
db.delete(shot)
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
shot = _screenshot_of(db, session, shot_id)
try:
debugsession.remove_screenshot(db, session, shot)
except debugsession.ScreenshotRefused as exc:
raise _screenshot_refused(exc.code) from None
db.commit()
return {"ok": True, "deleted": shot_id}