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:
+88
-17
@@ -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(
|
||||
|
||||
@@ -23,8 +23,6 @@ from sqlalchemy.orm import Session
|
||||
from app import debugsession, logsink
|
||||
from app.models import DebugScreenshot, DebugSession
|
||||
|
||||
_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssueFile:
|
||||
@@ -65,7 +63,8 @@ def files(db: Session, session: DebugSession) -> list[IssueFile]:
|
||||
.order_by(DebugScreenshot.id)
|
||||
)
|
||||
for n, shot in enumerate(shots, start=1):
|
||||
out.append(IssueFile(f"screenshot-{n}.{_EXT.get(shot.mime, 'bin')}", shot.data, shot.mime))
|
||||
ext = debugsession.IMAGE_TYPES[shot.mime]
|
||||
out.append(IssueFile(f"screenshot-{n}.{ext}", shot.data, shot.mime))
|
||||
return out
|
||||
|
||||
|
||||
@@ -73,5 +72,7 @@ def zipped(items: list[IssueFile]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for item in items:
|
||||
z.writestr(item.name, item.data)
|
||||
# The images are compressed already: deflating them again costs time and wins nothing.
|
||||
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
|
||||
z.writestr(item.name, item.data, compress_type=method)
|
||||
return buf.getvalue()
|
||||
|
||||
+57
-30
@@ -17,19 +17,20 @@ A private repository's attachment answers 404 to a browser not signed in to Gite
|
||||
composer's own preview shows our copies, never these links.
|
||||
|
||||
Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to
|
||||
upload, the links of those uploaded), so a failure half-way keeps the issue that exists and a retry
|
||||
sends only what is missing — never a second issue. Every step flushes; the caller commits, also
|
||||
when a step fails, so what did happen is kept.
|
||||
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 flushes; the caller holds the session's lock throughout (`debugsession.lock`) and commits,
|
||||
also when a step fails, so what did happen is kept. `debugsession.unfinished` reads that progress.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs, sysconfig
|
||||
from app import debugsession, logs, sysconfig
|
||||
from app.issues import bundle
|
||||
from app.models import DebugSession
|
||||
|
||||
@@ -44,7 +45,8 @@ Reason = Literal[
|
||||
"repo_not_found", # 404 on the repository
|
||||
"public_repo", # the repository is public — an issue would publish the reporter's log
|
||||
"issues_disabled", # the repository has its issue tracker switched off
|
||||
"rejected", # any other 4xx (a file type the instance does not allow, …)
|
||||
"rejected", # any other 4xx (a file type the instance does not allow, …), or an answer that is
|
||||
# not the API's (a redirect — an http:// address behind https —, a page that is not JSON)
|
||||
]
|
||||
|
||||
|
||||
@@ -91,7 +93,7 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
|
||||
extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__),
|
||||
)
|
||||
raise GiteaError("unreachable") from None
|
||||
if r.status_code < 400:
|
||||
if r.status_code < 300:
|
||||
return r
|
||||
# The status only: Gitea's message can quote back what was sent — the user's own title.
|
||||
log.warning(
|
||||
@@ -107,8 +109,25 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
|
||||
raise GiteaError("rejected")
|
||||
|
||||
|
||||
def _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
|
||||
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash: a
|
||||
crash after the issue was created would lose the record that it was."""
|
||||
try:
|
||||
data = r.json()
|
||||
# silent-ok: not JSON is one of the answers refused (and logged) just below.
|
||||
except ValueError:
|
||||
data = None
|
||||
if not isinstance(data, dict) or any(k not in data for k in keys):
|
||||
log.warning(
|
||||
"the issue tracker answered something that is not its API",
|
||||
extra=logs.event("issues.gitea.unexpected", status=r.status_code),
|
||||
)
|
||||
raise GiteaError("rejected")
|
||||
return data
|
||||
|
||||
|
||||
def _private_repo(client: httpx.Client, repo: str) -> dict:
|
||||
data = _call(client, "GET", f"/repos/{repo}").json()
|
||||
data = _json(_call(client, "GET", f"/repos/{repo}"))
|
||||
if not data.get("private"):
|
||||
raise GiteaError("public_repo")
|
||||
if not data.get("has_issues"):
|
||||
@@ -135,20 +154,30 @@ def _save(session: DebugSession, state: dict) -> None:
|
||||
session.draft = {**(session.draft or {}), "delivery": state}
|
||||
|
||||
|
||||
def deliver(db: Session, session: DebugSession) -> str:
|
||||
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step."""
|
||||
state = dict((session.draft or {}).get("delivery") or {})
|
||||
def deliver(
|
||||
db: Session, session: DebugSession, *, before_create: Callable[[], None] | None = None
|
||||
) -> str:
|
||||
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step.
|
||||
`before_create` runs right before a NEW issue is opened — after every check that could refuse
|
||||
it — and may raise to stop it (the caller's pace on new issues)."""
|
||||
state = debugsession.delivery(session)
|
||||
files = bundle.files(db, session)
|
||||
attachments = [f for f in files if f.name != "issue.md"]
|
||||
with _client(db) as (client, repo):
|
||||
_private_repo(client, repo)
|
||||
if "number" not in state:
|
||||
created = _call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues",
|
||||
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
|
||||
).json()
|
||||
if before_create is not None:
|
||||
before_create()
|
||||
created = _json(
|
||||
_call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues",
|
||||
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
|
||||
),
|
||||
"number",
|
||||
"html_url",
|
||||
)
|
||||
state = {
|
||||
"number": created["number"],
|
||||
"pending": [f.name for f in attachments],
|
||||
@@ -165,13 +194,16 @@ def deliver(db: Session, session: DebugSession) -> str:
|
||||
for item in attachments:
|
||||
if item.name not in state["pending"]:
|
||||
continue
|
||||
asset = _call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/assets",
|
||||
params={"name": item.name},
|
||||
files={"attachment": (item.name, item.data, item.mime)},
|
||||
).json()
|
||||
asset = _json(
|
||||
_call(
|
||||
client,
|
||||
"POST",
|
||||
f"/repos/{repo}/issues/{number}/assets",
|
||||
params={"name": item.name},
|
||||
files={"attachment": (item.name, item.data, item.mime)},
|
||||
),
|
||||
"browser_download_url",
|
||||
)
|
||||
state = {
|
||||
**state,
|
||||
"pending": [n for n in state["pending"] if n != item.name],
|
||||
@@ -187,8 +219,3 @@ def deliver(db: Session, session: DebugSession) -> str:
|
||||
_save(session, {**state, "images_shown": True})
|
||||
db.flush()
|
||||
return session.issue_url or ""
|
||||
|
||||
|
||||
def pending(session: DebugSession) -> list[str]:
|
||||
"""The files a half-finished delivery still has to send."""
|
||||
return list(((session.draft or {}).get("delivery") or {}).get("pending") or [])
|
||||
|
||||
+29
-10
@@ -127,7 +127,11 @@ def set_user(
|
||||
from the request middleware, which runs OUTSIDE the session middleware.
|
||||
|
||||
`debug_session` tags every line with the user's open debug session (E16). The caller has
|
||||
already checked it is theirs and open; a session's level arrives here as `level`/`until`."""
|
||||
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
|
||||
|
||||
The root logger's floor is tracked per elevation: a request without the session's header (an
|
||||
`<img>`, a `<video>` — the browser sends those itself) carries only the user's own level, and
|
||||
must not lower the floor a concurrent request of the session still needs."""
|
||||
_user_id.set(user_id)
|
||||
scope = _scope.get()
|
||||
if scope is None: # outside log_requests (a script, a unit test): this context only
|
||||
@@ -138,7 +142,8 @@ def set_user(
|
||||
scope.debug_session_id = debug_session
|
||||
scope.level = resolve_level(level) if elevated else None
|
||||
if user_id is not None:
|
||||
_track_elevation(user_id, scope.level, until)
|
||||
key = user_id if debug_session is None else (user_id, debug_session)
|
||||
_track_elevation(key, scope.level, until)
|
||||
|
||||
|
||||
def set_job(job_id: int | None) -> None:
|
||||
@@ -562,18 +567,21 @@ def current_level() -> str:
|
||||
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
|
||||
# test, a script — so the gate lets everything through there, as it did before the gate existed.
|
||||
_base_level = logging.NOTSET
|
||||
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
|
||||
_elevations: dict[int, tuple[int, datetime]] = {}
|
||||
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
|
||||
# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
|
||||
# its level, so it is tracked apart from the user's own requests.
|
||||
_ElevationKey = int | tuple[int, str]
|
||||
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
|
||||
_elevations_lock = threading.Lock()
|
||||
_root_applied: int | None = None
|
||||
|
||||
|
||||
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
|
||||
def _track_elevation(key: _ElevationKey, level: int | None, until: datetime | None) -> None:
|
||||
with _elevations_lock:
|
||||
if level is None or until is None:
|
||||
_elevations.pop(user_id, None)
|
||||
_elevations.pop(key, None)
|
||||
else:
|
||||
_elevations[user_id] = (level, until)
|
||||
_elevations[key] = (level, until)
|
||||
active = bool(_elevations)
|
||||
if active or _root_applied != _base_level:
|
||||
_apply_root_level()
|
||||
@@ -590,8 +598,8 @@ def _apply_root_level(force: bool = False) -> None:
|
||||
# different answer would otherwise apply them in either order, and the older one could win —
|
||||
# the root back at the base while an elevation is still active.
|
||||
with _elevations_lock:
|
||||
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
|
||||
del _elevations[user_id]
|
||||
for key in [k for k, (_, until) in _elevations.items() if until <= now]:
|
||||
del _elevations[key]
|
||||
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
|
||||
if force or lowest != _root_applied:
|
||||
logging.getLogger().setLevel(lowest)
|
||||
@@ -628,13 +636,24 @@ def forget_user(user_id: int) -> None:
|
||||
del _erased[stale]
|
||||
_erased[user_id] = now
|
||||
with _elevations_lock:
|
||||
_elevations.pop(user_id, None)
|
||||
for key in [
|
||||
k for k in _elevations if k == user_id or (isinstance(k, tuple) and k[0] == user_id)
|
||||
]:
|
||||
del _elevations[key]
|
||||
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
|
||||
# and every unexpired one. Recomputed here rather than left to whichever later request happens
|
||||
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
|
||||
_apply_root_level()
|
||||
|
||||
|
||||
def end_session_elevation(session_id: str) -> None:
|
||||
"""A debug session was stopped: its elevation ends now, not at its cap."""
|
||||
with _elevations_lock:
|
||||
for key in [k for k in _elevations if isinstance(k, tuple) and k[1] == session_id]:
|
||||
del _elevations[key]
|
||||
_apply_root_level()
|
||||
|
||||
|
||||
def is_erased(user_id: int | None) -> bool:
|
||||
if user_id is None:
|
||||
return False
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -168,9 +168,7 @@ def get_me(
|
||||
|
||||
def _live_session(db: Session, user: User) -> dict | None:
|
||||
session = debugsession.open_session(db, user)
|
||||
if session is None:
|
||||
return None
|
||||
return debugsession.serialize(session, debugsession.screenshot_count(db, session))
|
||||
return None if session is None else debugsession.view(db, session)
|
||||
|
||||
|
||||
@router.delete("/account")
|
||||
|
||||
@@ -10,6 +10,7 @@ Four parts:
|
||||
* screenshots: judged by their bytes, capped in size and count, served back only to their owner.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -274,6 +275,31 @@ def test_a_header_naming_anything_else_is_ignored_not_refused(gated, db, client,
|
||||
assert getattr(line, "elevated", False) is False, value
|
||||
|
||||
|
||||
def test_a_header_naming_the_own_session_past_its_cap_is_ignored(gated, db, client, make_user):
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
_expire(db, sid) # past its cap, not yet swept: still open in the table
|
||||
gated.records.clear()
|
||||
client.get("/api/debug-sessions", headers={"x-debug-session": sid})
|
||||
(line,) = _request_lines(gated)
|
||||
assert getattr(line, "debug_session_id", None) is None
|
||||
assert getattr(line, "elevated", False) is False
|
||||
|
||||
|
||||
def test_a_request_without_the_header_keeps_the_sessions_floor(gated):
|
||||
"""The browser's own requests (an <img>, a <video>) carry no header: they must not lower the
|
||||
root level a concurrent request of the session still needs."""
|
||||
root = logging.getLogger()
|
||||
until = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
request = contextvars.copy_context() # set_user writes the context: keep it this test's own
|
||||
request.run(logs.set_user, 5, "TRACE", until, "a" * 16)
|
||||
assert root.level == logs.resolve_level("TRACE")
|
||||
request.run(logs.set_user, 5, "INFO", None, None) # the same user, a request without the header
|
||||
assert root.level == logs.resolve_level("TRACE")
|
||||
logs.end_session_elevation("a" * 16) # Stop
|
||||
assert root.level == logging.INFO
|
||||
|
||||
|
||||
def test_a_session_never_lowers_the_users_own_level():
|
||||
now = datetime.now(timezone.utc)
|
||||
user = User(diagnostics_level="TRACE", diagnostics_until=now + timedelta(hours=3))
|
||||
@@ -454,6 +480,75 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
|
||||
assert r.status_code == 404 and r.json()["detail"]["reason"] == "screenshot"
|
||||
|
||||
|
||||
def test_a_sent_session_keeps_its_screenshots(db, recording):
|
||||
client, sid = recording
|
||||
shot = _upload(client, sid, _PNG).json()["screenshot"]["id"]
|
||||
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
|
||||
db.commit()
|
||||
# The issue carries it already, and a half-sent one names its screenshots by their order.
|
||||
r = client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}")
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "screenshot_already_sent"
|
||||
|
||||
|
||||
def test_a_users_screenshots_are_capped_across_their_sessions(recording, monkeypatch):
|
||||
client, sid = recording
|
||||
monkeypatch.setattr(debugsession, "SCREENSHOT_USER_MAX_BYTES", len(_PNG) * 2)
|
||||
assert _upload(client, sid, _PNG).status_code == 200
|
||||
client.post(f"/api/debug-sessions/{sid}/stop")
|
||||
other = _start(client)["id"]
|
||||
assert _upload(client, other, _PNG).status_code == 200
|
||||
r = _upload(client, other, _PNG)
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "screenshot_storage_full"
|
||||
|
||||
|
||||
def test_new_sessions_are_paced_but_joining_one_is_not(db, make_user, as_user, monkeypatch):
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
monkeypatch.setattr(routes, "_new_sessions", RateLimiter(max_events=1, window_seconds=3600))
|
||||
client = as_user(make_user())
|
||||
sid = _start(client)["id"]
|
||||
assert _start(client)["id"] == sid # another tab joining
|
||||
client.post(f"/api/debug-sessions/{sid}/stop")
|
||||
r = client.post("/api/debug-sessions", json={"level": "DEBUG"})
|
||||
assert r.status_code == 429 and r.json()["detail"]["reason"] == "debug_session_rate_limited"
|
||||
|
||||
|
||||
def test_two_starts_at_once_join_the_one_that_won(db, make_user, as_user, monkeypatch):
|
||||
user = make_user()
|
||||
client = as_user(user)
|
||||
won = _start(client)["id"]
|
||||
|
||||
def raced(db_, user_, level):
|
||||
# The loser of the race: past the "is one open?" read, its insert meets the index.
|
||||
db_.add(
|
||||
DebugSession(
|
||||
id="0" * 16,
|
||||
user_id=user_.id,
|
||||
level=level,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
until=datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
db_.flush()
|
||||
|
||||
monkeypatch.setattr(debugsession, "start", raced)
|
||||
r = client.post("/api/debug-sessions", json={"level": "DEBUG"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["session"]["id"] == won
|
||||
|
||||
|
||||
def test_the_export_is_paced(recording, monkeypatch):
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
client, sid = recording
|
||||
monkeypatch.setattr(routes, "_exports", RateLimiter(max_events=1, window_seconds=60))
|
||||
assert client.get(f"/api/debug-sessions/{sid}/export").status_code == 200
|
||||
r = client.get(f"/api/debug-sessions/{sid}/export")
|
||||
assert r.status_code == 429 and r.json()["detail"]["reason"] == "export_rate_limited"
|
||||
|
||||
|
||||
def test_the_upload_path_left_out_of_the_evidence_is_the_real_one():
|
||||
assert debugsession.INGEST_PATH in {r.path for r in diagnostics_routes.router.routes}
|
||||
|
||||
@@ -565,6 +660,9 @@ class FakeGitea:
|
||||
self.has_issues = True
|
||||
self.token_ok = True
|
||||
self.fail_asset: str | None = None
|
||||
self.fail_patch = False
|
||||
self.redirect = False # an http:// address behind a proxy that sends it to https
|
||||
self.garbage_asset = False # a 2xx that is not the API's answer (a proxy's page)
|
||||
self.patched_body: str | None = None
|
||||
|
||||
def handler(self, request: httpx.Request) -> httpx.Response:
|
||||
@@ -572,6 +670,8 @@ class FakeGitea:
|
||||
self.calls.append((request.method, path))
|
||||
if request.headers.get("authorization") != "token t0ken" or not self.token_ok:
|
||||
return httpx.Response(401, json={"message": "invalid username, password or token"})
|
||||
if self.redirect:
|
||||
return httpx.Response(301, headers={"location": "https://git.example/"}, text="moved")
|
||||
if request.method == "GET" and path == "/repos/o/r":
|
||||
return httpx.Response(
|
||||
200,
|
||||
@@ -591,10 +691,16 @@ class FakeGitea:
|
||||
if name == self.fail_asset:
|
||||
self.fail_asset = None
|
||||
return httpx.Response(500, json={"message": "boom"})
|
||||
if self.garbage_asset:
|
||||
self.garbage_asset = False
|
||||
return httpx.Response(200, text="<html>sign in</html>")
|
||||
return httpx.Response(
|
||||
201, json={"name": name, "browser_download_url": f"https://git.example/a/{name}"}
|
||||
)
|
||||
if request.method == "PATCH" and path == "/repos/o/r/issues/7":
|
||||
if self.fail_patch:
|
||||
self.fail_patch = False
|
||||
return httpx.Response(502, json={"message": "bad gateway"})
|
||||
self.patched_body = json.loads(request.content)["body"]
|
||||
return httpx.Response(201, json={"number": 7})
|
||||
if request.method == "POST" and path == "/markdown":
|
||||
@@ -698,6 +804,53 @@ def test_a_send_that_stops_half_way_keeps_the_issue_and_the_retry_finishes_it(
|
||||
assert len(gitea_fake.uploads()) == 3 # client.log, the failed server.log, then server.log
|
||||
|
||||
|
||||
def test_a_failed_edit_that_shows_the_screenshots_is_finished_by_the_retry(
|
||||
gitea_fake, db, client, make_user
|
||||
):
|
||||
gitea_fake.fail_patch = True
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
_upload(client, sid, _PNG)
|
||||
_ready(client, sid)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_unreachable"
|
||||
# Every file is up, but the body does not show the screenshot yet: the send is not finished.
|
||||
assert client.get(f"/api/debug-sessions/{sid}").json()["pending_files"] == ["screenshot-1.png"]
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 200 and r.json()["session"]["pending_files"] == []
|
||||
assert "" in gitea_fake.patched_body
|
||||
assert len(gitea_fake.uploads()) == 3 # nothing uploaded twice
|
||||
|
||||
|
||||
def test_an_answer_that_is_not_the_api_is_refused_and_the_issue_stays_linked(
|
||||
gitea_fake, db, client, make_user
|
||||
):
|
||||
gitea_fake.garbage_asset = True
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
_ready(client, sid)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_rejected"
|
||||
assert client.get(f"/api/debug-sessions/{sid}").json()["issue_url"] is not None
|
||||
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200
|
||||
# Still the one issue.
|
||||
assert [m for m, p in gitea_fake.calls if p == "/repos/o/r/issues"] == ["POST"]
|
||||
|
||||
|
||||
def test_a_redirecting_tracker_is_refused_not_a_crash(gitea_fake, db, make_user, as_user):
|
||||
gitea_fake.redirect = True
|
||||
admin = as_user(make_user(role="admin"))
|
||||
r = admin.post("/api/admin/config/test-issues")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_rejected"
|
||||
|
||||
|
||||
def test_the_delivery_survives_a_save_of_the_draft():
|
||||
session = DebugSession(draft={"title": "t", "delivery": {"number": 7, "pending": ["a.log"]}})
|
||||
debugsession.save_draft(session, {"title": "t2", "body": "b", "excluded": [], "form": {}})
|
||||
assert session.draft["title"] == "t2"
|
||||
assert debugsession.unfinished(session) == ["a.log"]
|
||||
|
||||
|
||||
def test_a_report_needs_a_title_and_a_wrong_token_says_so(gitea_fake, db, client, make_user):
|
||||
_signed_in(client, make_user)
|
||||
sid = _start(client)["id"]
|
||||
@@ -723,7 +876,27 @@ def test_new_issues_are_paced_per_user(gitea_fake, db, make_user, as_user, monke
|
||||
second = _start(client)["id"]
|
||||
_ready(client, second)
|
||||
r = client.post(f"/api/debug-sessions/{second}/issue")
|
||||
assert r.status_code == 429 and r.json()["detail"]["reason"] == "issues_rate_limited"
|
||||
assert r.status_code == 429 and r.json()["detail"] == {
|
||||
"code": "refused",
|
||||
"reason": "issues_rate_limited",
|
||||
"limit": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_an_attempt_refused_before_the_issue_opened_is_not_counted(
|
||||
gitea_fake, db, make_user, as_user, monkeypatch
|
||||
):
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
monkeypatch.setattr(routes, "_new_issues", RateLimiter(max_events=1, window_seconds=3600))
|
||||
client = as_user(make_user())
|
||||
sid = _start(client)["id"]
|
||||
_ready(client, sid)
|
||||
gitea_fake.token_ok = False
|
||||
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 422
|
||||
gitea_fake.token_ok = True
|
||||
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 200
|
||||
|
||||
|
||||
def test_the_preview_is_the_trackers_own_rendering(gitea_fake, db, make_user, as_user):
|
||||
|
||||
Reference in New Issue
Block a user