fix(diagnostics): deep-review round 3 — a keyboard owner that is not a Tab trap

Round 2 made the step prompt a blocking layer so that `j` and the `g` chords
would not reach the page behind it. The dialog it sits in gave up its Tab trap
because of that, while the prompt's own replacement trap covered only its own
two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or
Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now
separates the two questions it had folded into one flag: a layer can own the
keyboard and still leave the Tab trap to the dialog below it. The mirror case,
a dialog opening over the prompt, falls out of the same split.

A send's claim is now a lease with an owner. Every progress report re-stamps
it, so a send slower than the lease cannot lose it while it is still running,
and only the send that took a claim can give it back. The issue_create audit
row is staged inside the send and rides the same checkpoint as `issue_url`, so
the trail cannot be missing for an issue that exists. The attachment-name
prefix has one definition, shared by the producer and both readers.

A running send is reported to the UI. A reload or a second tab used to render
it exactly like a send that STOPPED, and offered a retry the server was certain
to refuse; the composer now says the report is being sent, holds the draft, and
puts both the retry and Create issue out. The past-sessions list says the same
for the same reason — it is the list a user opens to check on a send, and both
"draft" and "half sent" describe one that died. `/preview` and the retry path
got a pace of their own, and the per-user byte cap took the user-scoped lock
that makes it a cap instead of a guess.

Verified in a real browser on localdev: Tab cycles the whole dialog with the
prompt open, the prompt still swallows the page's keys, and the issue link is
focusable where the composer puts it. All 24 regression tests mutation-checked.
This commit is contained in:
2026-09-20 02:43:56 +02:00
parent bc03e82687
commit 2fe58073e4
20 changed files with 718 additions and 137 deletions
+70 -20
View File
@@ -21,6 +21,7 @@ import secrets
from datetime import datetime, timedelta, timezone
from enum import StrEnum
from typing import Literal, get_args
from uuid import uuid4
from sqlalchemy import and_, delete, func, not_, select, update
from sqlalchemy.orm import Session, defer
@@ -38,6 +39,11 @@ 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"}
# What a screenshot is called in the zip and on the issue. `issues/bundle.py` builds the names from
# this and `IMAGE_TYPES`; this module and `issues/gitea.py` read them back to tell a screenshot's
# link from a log's. One definition, because three copies of a name is how the next rename breaks
# `images_pending` into silence.
SCREENSHOT_PREFIX = "screenshot-"
# 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
@@ -180,7 +186,7 @@ def images_pending(session: DebugSession) -> bool:
(every upload went, the edit that shows them did not)."""
state = delivery(session)
shown = state.get("images_shown")
return not shown and any(n.startswith("screenshot-") for n in state.get("links") or {})
return not shown and any(n.startswith(SCREENSHOT_PREFIX) for n in state.get("links") or {})
def unfinished(session: DebugSession) -> bool:
@@ -190,33 +196,63 @@ def unfinished(session: DebugSession) -> bool:
# A send claims the session while it talks to the tracker, instead of holding the row's lock across
# those calls: a Stop, an autosave or a screenshot would queue behind a lock for the whole send.
# They see the claim and are refused at once instead. A claim older than this was left by a request
# that died mid-send (the process restarted), and the next send takes it over.
# They see the claim and are refused at once instead. A claim not renewed for this long was left by
# a request that died mid-send (the process restarted), and the next send takes it over — which is
# why a live send RENEWS it every time it reports progress (`set_delivery`). A claim is a lease, and
# a send slower than the lease must not lose it while it is still running.
SEND_CLAIM_SECONDS = 600
def _claim(session: DebugSession) -> dict:
claim = delivery(session).get("sending")
return claim if isinstance(claim, dict) else {}
def _stamp(token: str) -> dict:
return {"at": datetime.now(timezone.utc).isoformat(), "by": token}
def sending(session: DebugSession) -> bool:
"""Whether a send is under way right now (a live claim)."""
since = delivery(session).get("sending")
if not since:
at = _claim(session).get("at")
if not at:
return False
started = datetime.fromisoformat(since)
return (datetime.now(timezone.utc) - started).total_seconds() < SEND_CLAIM_SECONDS
return (datetime.now(timezone.utc) - datetime.fromisoformat(at)).total_seconds() < (
SEND_CLAIM_SECONDS
)
def claim_send(session: DebugSession) -> None:
"""Mark the session as being sent. The caller holds the lock and commits, which is what makes
the claim visible to everyone else."""
set_delivery(session, {**delivery(session), "sending": datetime.now(timezone.utc).isoformat()})
def claim_send(session: DebugSession) -> str:
"""Mark the session as being sent, and return the token that OWNS this claim. The caller holds
the lock and commits, which is what makes the claim visible to everyone else."""
token = uuid4().hex
_write_delivery(session, {**delivery(session), "sending": _stamp(token)})
return token
def release_send(session: DebugSession) -> None:
state = {k: v for k, v in delivery(session).items() if k != "sending"}
set_delivery(session, state)
def release_send(session: DebugSession, token: str) -> None:
"""Give back our OWN claim, and only ours: one that aged out and was taken over belongs to the
send holding it now, and handing that one back would let a third send in beside it."""
if _claim(session).get("by") != token:
return
_write_delivery(session, {k: v for k, v in delivery(session).items() if k != "sending"})
def set_delivery(session: DebugSession, state: dict) -> None:
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it)."""
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it).
The claim is not the caller's to carry. `gitea.deliver` threads ONE snapshot of this dict
through a whole send, so a progress report that carried the claim wrote a stale one back at
every step, and a send longer than the lease then expired while still running. It is taken from
the row and re-stamped here instead: a send still reporting progress is still alive."""
claim = _claim(session)
state = {k: v for k, v in state.items() if k != "sending"}
if claim:
state["sending"] = _stamp(claim["by"])
_write_delivery(session, state)
def _write_delivery(session: DebugSession, state: dict) -> None:
# A new dict, so the JSONB column sees the change.
session.draft = {**(session.draft or {}), "delivery": state}
@@ -233,9 +269,13 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
# A send that stopped half-way: the files it has still to upload, and whether the issue's
# body has still to be edited to show the screenshots it already carries.
# body has still to be edited to show the screenshots it already carries. Both are true of a
# send that is RUNNING too — every step commits its progress — so `sending` goes with them:
# without it a reload or a second tab renders a live send as a failed one, and offers a
# retry that is certain to be refused.
"pending_files": not_uploaded(session),
"images_pending": images_pending(session),
"sending": sending(session),
"screenshots": screenshots,
"draft": session.draft,
}
@@ -275,6 +315,14 @@ def sniff(data: bytes) -> str | None:
return None
def lock_user(db: Session, user_id: int) -> None:
"""Hold this user's own lock for the rest of the transaction. A session's row lock bounds what
THAT session may keep; the byte cap below is the user's across all of them, and a lock on one
session cannot serialize a check that reads them all — two uploads to two sessions both read the
total before either commits, and both pass."""
db.execute(select(func.pg_advisory_xact_lock(user_id)))
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
return (
db.scalar(
@@ -295,15 +343,17 @@ class ScreenshotRefused(Exception):
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
"""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."""
"""Keep one screenshot. Locks the user and the session, and flushes; the caller commits.
Refused while a send is under way or 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: two uploads at once cannot both pass the count.
# Under both locks: two uploads at once cannot both pass the count (the session's), nor both
# pass the user's byte cap from two different sessions (the user's). Always in this order.
lock_user(db, session.user_id)
lock(db, session)
if sending(session):
raise ScreenshotRefused("sending")
+2 -1
View File
@@ -64,7 +64,8 @@ def files(db: Session, session: DebugSession) -> list[IssueFile]:
)
for n, shot in enumerate(shots, start=1):
ext = debugsession.IMAGE_TYPES[shot.mime]
out.append(IssueFile(f"screenshot-{n}.{ext}", shot.data, shot.mime))
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
out.append(IssueFile(name, shot.data, shot.mime))
return out
+20 -10
View File
@@ -20,9 +20,10 @@ Delivery keeps its progress in the session's draft (`delivery`: the issue number
upload, the links of those uploaded, whether the body shows the screenshots yet), so a failure
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
Every step is checkpointed (the caller's `db.commit`), so what did happen is kept whatever fails
next — and no transaction is held open across these calls. The caller claims the session for the
send first (`debugsession.claim_send`), which is what keeps a second send, an autosave or a
screenshot out of the way. `debugsession.not_uploaded` / `images_pending` read that progress.
next — and the first checkpoint comes before the first call, so no row lock and no read snapshot is
held across them. The caller claims the session for the send first (`debugsession.claim_send`),
which is what keeps a second send, an autosave or a screenshot out of the way; the claim is renewed
by every checkpoint. `debugsession.not_uploaded` / `images_pending` read that progress.
"""
from collections.abc import Callable, Iterator
@@ -161,16 +162,23 @@ def deliver(
*,
checkpoint: Callable[[], None],
before_create: Callable[[], None] | None = None,
on_created: Callable[[], None] | None = None,
) -> str:
(
"""Send the session's issue, or finish sending it; returns its URL. `checkpoint` persists what
has happened so far and is called after every step (the caller's commit — this module does not
decide when a transaction ends). `before_create` runs right before the tracker is asked to open
a NEW issue — after the checks that can refuse it without asking — and may raise to stop it.""",
)
"""Send the session's issue, or finish sending it; returns its URL.
`checkpoint` persists what has happened so far and is called after every step (the caller's
commit — this module does not decide when a transaction ends). `before_create` runs right before
the tracker is asked to open a NEW issue, after the checks that can refuse it without asking,
and may raise to stop it; `on_created` runs right after one was opened, in time to be committed
by the SAME checkpoint that first writes `issue_url` — anything the caller must not be able to
lose separately from the link belongs there.
"""
state = debugsession.delivery(session)
files = bundle.files(db, session)
attachments = [f for f in files if f.name != "issue.md"]
# Reading the bundle and the tracker's settings opened a transaction; end it before the first
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
checkpoint()
with _client(db) as (client, repo):
_private_repo(client, repo)
if "number" not in state:
@@ -197,6 +205,8 @@ def deliver(
}
session.issue_url = created["html_url"]
debugsession.set_delivery(session, state)
if on_created is not None:
on_created()
checkpoint()
log.info(
"issue created",
@@ -223,7 +233,7 @@ def deliver(
}
debugsession.set_delivery(session, state)
checkpoint()
shots = [n for n in state["links"] if n.startswith("screenshot-")]
shots = [n for n in state["links"] if n.startswith(debugsession.SCREENSHOT_PREFIX)]
if shots and not state.get("images_shown"):
images = "\n".join(f"![{n}]({state['links'][n]})" for n in sorted(shots))
body = f"{bundle.body_of(session)}\n\n---\n\n{images}"
+40 -19
View File
@@ -188,6 +188,15 @@ def export_session(
# one, or an attempt refused before the tracker was asked, 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)
# Attempts on the tracker per hour, creating or finishing. Deliberately NOT the budget above: a
# half-sent issue must always be finishable, so the pace that guards creating one must never be what
# stands between a user and their own report. This one only bounds a loop — every attempt re-reads
# the whole bundle into memory and re-offers the pending file to the tracker, and a retry that is
# refused for good (the tracker's own limits) never leaves `pending`. No hand reaches it.
_sends = RateLimiter(max_events=60, window_seconds=3600)
# The composer's live preview, rendered BY the tracker: one authenticated call each, with our token.
# react-query debounces it to a handful a minute; this bounds what a loop can aim at the tracker.
_previews = RateLimiter(max_events=60, window_seconds=60)
def _issues_rate_limited() -> HTTPException:
@@ -216,7 +225,9 @@ def send_issue(
raise refused(422, "issue_title_required")
if _new_issues.exhausted(key):
raise _issues_rate_limited()
debugsession.claim_send(session)
if not _sends.allow(key):
raise refused(429, "issue_rate_limited", limit=_sends.max_events)
token = debugsession.claim_send(session)
db.commit() # the claim is what everything else sees; the lock ends here
def count_new_issue() -> None:
@@ -224,7 +235,13 @@ def send_issue(
raise _issues_rate_limited()
try:
gitea.deliver(db, session, checkpoint=db.commit, before_create=count_new_issue)
gitea.deliver(
db,
session,
checkpoint=db.commit,
before_create=count_new_issue,
on_created=lambda: _record_created(db, user, session),
)
except gitea.GiteaError as exc:
# Keep what did happen: an issue created before an upload failed stays linked, and the
# retry does only what is still missing.
@@ -232,18 +249,17 @@ def send_issue(
except HTTPException:
raise # the pace, refused before the tracker was asked
finally:
_end_send(db, user, session, new)
_end_send(db, session, token)
return {"ok": True, "session": _view(db, session)}
def _end_send(db: Session, user: User, session: DebugSession, new: bool) -> None:
"""Give the claim back, and record an issue this send opened — whatever else happened. A
failure here must not replace the failure that brought us: a dead transaction (the database
went away mid-send) is rolled back, and the claim then simply ages out."""
def _end_send(db: Session, session: DebugSession, token: str) -> None:
"""Give the claim back, whatever else happened. A failure here must not replace the failure that
brought us: a dead transaction (the database went away mid-send) is rolled back, and the claim
then simply ages out."""
try:
debugsession.lock(db, session)
debugsession.release_send(session)
_record_created(db, user, session, new)
debugsession.release_send(session, token)
db.commit()
except Exception: # noqa: BLE001 — whatever went wrong here, the send's own failure is the news
db.rollback()
@@ -253,16 +269,19 @@ def _end_send(db: Session, user: User, session: DebugSession, new: bool) -> None
)
def _record_created(db: Session, user: User, session: DebugSession, new: bool) -> None:
if new and session.issue_url:
audit.record(
db,
user.id,
AuditAction.ISSUE_CREATE,
target_type="debug_session",
target_id=session.id,
summary=session.issue_url,
)
def _record_created(db: Session, user: User, session: DebugSession) -> None:
"""The trail for the one act that takes a user's log off this instance. Staged inside the send,
so the checkpoint that first writes `issue_url` commits BOTH — recorded later it could be lost
on its own (the process dies mid-send), and no retry could write it afterwards: the next send
sees the link and knows the issue is not new."""
audit.record(
db,
user.id,
AuditAction.ISSUE_CREATE,
target_type="debug_session",
target_id=session.id,
summary=session.issue_url or "",
)
class PreviewIn(BaseModel):
@@ -279,6 +298,8 @@ def preview_issue(
"""The body as the tracker will render it. Gitea sanitises its own output; the composer still
shows it in a sandboxed frame, never in the app's own document."""
owned_or_404(db, DebugSession, sid, user, what="debug_session")
if not _previews.allow(str(user.id)):
raise refused(429, "preview_rate_limited")
try:
return {"ok": True, "html": gitea.preview(db, body.text)}
except gitea.GiteaError as exc: