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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user