fix(diagnostics): deep-review round 2 — a send that claims, not one that blocks
The send no longer holds the session's row lock while it talks to the tracker: it claims the session, gives the lock back, and checkpoints each step. A Stop is never held behind a send, while a draft, a screenshot or a second send are refused at once instead of queueing. The claim ages out, so a request that died mid-send does not strand the session. What the composer says is what happened: an attachment that went is no longer named as one that did not (the missing body edit is its own line), and the issue's screenshot count comes from the server when the list cannot be read, so a failed read no longer blocks saving the draft. The zip is fetched as a request, so its pace is a message and not a saved error page. Screenshots of a finished report can be removed again; only one still being sent is held. A recording's log elevation can no longer outlive it. The step prompt keeps the keyboard (the page's keys stay quiet) and traps Tab itself. An image pasted into a dialog opened over the composer stays that dialog's.
This commit is contained in:
+71
-16
@@ -132,11 +132,17 @@ def from_header(db: Session, user: User, value: str | None) -> DebugSession | No
|
||||
|
||||
def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]:
|
||||
"""The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the
|
||||
session's while one is named — whichever records more."""
|
||||
session's while one is named — whichever records more.
|
||||
|
||||
A named session also caps the expiry at its own: this pair is what the process tracks under the
|
||||
session (app/logs.py), and an expiry outliving the recording would hold the root logger down
|
||||
after it ended."""
|
||||
own = diagnostics.effective_level(user)
|
||||
until = user.diagnostics_until if own != diagnostics.BASE else None
|
||||
if session is None or _DETAIL[session.level] <= _DETAIL[own]:
|
||||
if session is None:
|
||||
return own, until
|
||||
if _DETAIL[session.level] <= _DETAIL[own]:
|
||||
return own, min(until, session.until) if until else session.until
|
||||
return session.level, session.until
|
||||
|
||||
|
||||
@@ -163,14 +169,56 @@ 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)."""
|
||||
def not_uploaded(session: DebugSession) -> list[str]:
|
||||
"""The files a send that stopped half-way has still to upload — by name, as the issue carries
|
||||
them. What the composer names to the user."""
|
||||
return list(delivery(session).get("pending") or [])
|
||||
|
||||
|
||||
def images_pending(session: DebugSession) -> bool:
|
||||
"""Whether the issue's body still has to be edited to show the screenshots it already carries
|
||||
(every upload went, the edit that shows them did not)."""
|
||||
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
|
||||
shown = state.get("images_shown")
|
||||
return not shown and any(n.startswith("screenshot-") for n in state.get("links") or {})
|
||||
|
||||
|
||||
def unfinished(session: DebugSession) -> bool:
|
||||
"""Whether a send owes the issue anything — the retry's question."""
|
||||
return bool(not_uploaded(session)) or images_pending(session)
|
||||
|
||||
|
||||
# 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.
|
||||
SEND_CLAIM_SECONDS = 600
|
||||
|
||||
|
||||
def sending(session: DebugSession) -> bool:
|
||||
"""Whether a send is under way right now (a live claim)."""
|
||||
since = delivery(session).get("sending")
|
||||
if not since:
|
||||
return False
|
||||
started = datetime.fromisoformat(since)
|
||||
return (datetime.now(timezone.utc) - started).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 release_send(session: DebugSession) -> None:
|
||||
state = {k: v for k, v in delivery(session).items() if k != "sending"}
|
||||
set_delivery(session, state)
|
||||
|
||||
|
||||
def set_delivery(session: DebugSession, state: dict) -> None:
|
||||
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it)."""
|
||||
# A new dict, so the JSONB column sees the change.
|
||||
session.draft = {**(session.draft or {}), "delivery": state}
|
||||
|
||||
|
||||
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
|
||||
@@ -184,8 +232,10 @@ 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: what it still owes.
|
||||
"pending_files": unfinished(session),
|
||||
# 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.
|
||||
"pending_files": not_uploaded(session),
|
||||
"images_pending": images_pending(session),
|
||||
"screenshots": screenshots,
|
||||
"draft": session.draft,
|
||||
}
|
||||
@@ -253,9 +303,10 @@ def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScre
|
||||
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.
|
||||
# Under the lock: two uploads at once cannot both pass the count.
|
||||
lock(db, session)
|
||||
if sending(session):
|
||||
raise ScreenshotRefused("sending")
|
||||
if session.issue_url is not None:
|
||||
raise ScreenshotRefused("already_sent")
|
||||
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
|
||||
@@ -275,10 +326,14 @@ def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScre
|
||||
|
||||
|
||||
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."""
|
||||
"""Delete one screenshot, under the lock. Refused while a send is under way or unfinished: a
|
||||
delivery names the screenshots by their order, and removing one would renumber what it still
|
||||
has to send. Once the issue has everything, this only drops OUR copy — the issue keeps its
|
||||
own. The caller commits."""
|
||||
lock(db, session)
|
||||
if session.issue_url is not None:
|
||||
if sending(session):
|
||||
raise ScreenshotRefused("sending")
|
||||
if unfinished(session):
|
||||
raise ScreenshotRefused("already_sent")
|
||||
db.delete(shot)
|
||||
db.flush()
|
||||
|
||||
Reference in New Issue
Block a user