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
+88 -17
View File
@@ -32,6 +32,12 @@ HEADER = "x-debug-session"
MAX_MINUTES = diagnostics.SESSION_MINUTES
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
SCREENSHOTS_MAX = 8
# What one user may keep in screenshots across ALL their sessions (until retention takes them): the
# per-session cap alone would let Start → 8 images → Stop repeat without end.
SCREENSHOT_USER_MAX_BYTES = 100 * 1024 * 1024
# The screenshot types kept, and the extension each is exported and sent under — one table, so an
# accepted type always has a name.
IMAGE_TYPES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
# bundle says how many it left out rather than growing without bound.
BUNDLE_MAX = 5_000
@@ -48,15 +54,16 @@ class EndReason(StrEnum):
EXPIRED = "expired"
def _is_live(session: DebugSession, now: datetime) -> bool:
return session.ended_at is None and session.until > now
def is_live(session: DebugSession, now: datetime | None = None) -> bool:
"""Open and within its cap — THE liveness rule: an open session past its cap is over."""
return session.ended_at is None and session.until > (now or datetime.now(timezone.utc))
def open_session(db: Session, user: User) -> DebugSession | None:
"""The user's live session, or None. Read-only: one past its cap is over and reads as None;
`start` and the hourly sweep are what close it."""
session = _unclosed(db, user)
if session is None or not _is_live(session, datetime.now(timezone.utc)):
if session is None or not is_live(session, datetime.now(timezone.utc)):
return None
return session
@@ -71,11 +78,11 @@ def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
"""Open a session, or return the live one — a second tab pressing Start joins the recording
rather than ending it. One past its cap is closed first: the one-open-per-user index would
refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both
get here: the index refuses the second, and the caller retries once."""
get here: the index refuses the second, and the caller joins the one that won."""
now = datetime.now(timezone.utc)
current = _unclosed(db, user)
if current is not None:
if _is_live(current, now):
if is_live(current, now):
return current
end(current, EndReason.EXPIRED)
db.flush()
@@ -91,6 +98,12 @@ def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
return session
def stop(session: DebugSession) -> None:
"""The user's Stop. Past its cap the session had already ended on its own, and a late Stop must
not rename that."""
end(session, EndReason.STOPPED if is_live(session) else EndReason.EXPIRED)
def end(session: DebugSession, reason: EndReason) -> None:
"""Close a session. An expired one is dated at its cap, not at whenever it was noticed."""
if session.ended_at is not None:
@@ -111,7 +124,7 @@ def from_header(db: Session, user: User, value: str | None) -> DebugSession | No
if (
session is None
or session.user_id != user.id
or not _is_live(session, datetime.now(timezone.utc))
or not is_live(session, datetime.now(timezone.utc))
):
return None
return session
@@ -127,11 +140,37 @@ def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime |
return session.level, session.until
def lock(db: Session, session: DebugSession) -> None:
"""Re-read the session under a row lock, held until the caller commits. Everything that changes
what an issue is made of — the draft, the screenshots, the send itself — takes it first: a send
in flight sees none of them change under it, and each of them sees a send that committed."""
db.refresh(session, with_for_update=True)
def save_draft(session: DebugSession, draft: dict) -> None:
"""Keep the composer's draft (the caller validated and commits). Shape: `title`, `body` (the
rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was)."""
session.draft = draft
"""Keep the composer's draft (the caller validated, locked and commits). Shape: `title`, `body`
(the rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was). A delivery's progress
is not the composer's to replace, and is kept."""
progress = (session.draft or {}).get("delivery")
session.draft = {**draft, "delivery": progress} if progress else draft
# --- a delivery's progress -----------------------------------------------------------------
# Written by app/issues/gitea.py into the draft; read HERE, so the send's decision and what the
# composer shows are one reading of it.
def delivery(session: DebugSession) -> dict:
return dict((session.draft or {}).get("delivery") or {})
def unfinished(session: DebugSession) -> list[str]:
"""What a send that stopped half-way still owes its issue: the files not yet uploaded, and the
screenshots uploaded but not yet shown in its body (the edit that shows them failed)."""
state = delivery(session)
missing = list(state.get("pending") or [])
if not state.get("images_shown"):
missing += [n for n in state.get("links") or {} if n.startswith("screenshot-")]
return missing
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
@@ -145,13 +184,18 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
# A send that stopped half-way (app/issues/gitea.py): the files still to go.
"pending_files": ((session.draft or {}).get("delivery") or {}).get("pending") or [],
# A send that stopped half-way: what it still owes.
"pending_files": unfinished(session),
"screenshots": screenshots,
"draft": session.draft,
}
def view(db: Session, session: DebugSession) -> dict:
"""The session as the API serves it — one shape wherever it is served."""
return serialize(session, screenshot_count(db, session)) or {}
def screenshot_count(db: Session, session: DebugSession) -> int:
return (
db.scalar(
@@ -172,7 +216,7 @@ _SIGNATURES = (
def sniff(data: bytes) -> str | None:
"""The image type `data` really is — PNG, JPEG or WebP — or None."""
"""The image type `data` really is — one of IMAGE_TYPES — or None."""
for magic, mime in _SIGNATURES:
if data.startswith(magic):
return mime
@@ -181,6 +225,17 @@ def sniff(data: bytes) -> str | None:
return None
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
return (
db.scalar(
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
.where(DebugSession.user_id == user_id)
)
or 0
)
class ScreenshotRefused(Exception):
"""A screenshot that cannot be kept; `code` is the refusal's machine word."""
@@ -190,17 +245,23 @@ class ScreenshotRefused(Exception):
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
"""Keep one screenshot. Flushes; the caller commits. Refused once the issue was sent (it can no
longer carry it), past the per-session count, or when it is not an image we can show."""
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
"""Keep one screenshot. Locks the session and flushes; the caller commits. Refused once the
issue was sent (it can no longer carry it), past the per-session count or the user's storage,
or when it is not an image we can show."""
if not data:
raise ScreenshotRefused("empty")
mime = sniff(data)
if mime is None:
raise ScreenshotRefused("not_an_image")
# Under the lock: a send in flight is waited for (and then refuses this), and two uploads at
# once cannot both pass the count.
lock(db, session)
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
raise ScreenshotRefused("too_many")
if stored_screenshot_bytes(db, session.user_id) + len(data) > SCREENSHOT_USER_MAX_BYTES:
raise ScreenshotRefused("storage_full")
shot = DebugScreenshot(
session_id=session.id,
created_at=datetime.now(timezone.utc),
@@ -213,6 +274,16 @@ def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScre
return shot
def remove_screenshot(db: Session, session: DebugSession, shot: DebugScreenshot) -> None:
"""Delete one screenshot, under the lock. Refused once the issue was sent: it carries this one
already, and a delivery names the screenshots by their order. The caller commits."""
lock(db, session)
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
db.delete(shot)
db.flush()
def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
"""The session's screenshots, oldest first, WITHOUT their bytes loaded."""
return list(