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:
@@ -18,7 +18,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import BackgroundTasks
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app import debugsession, logs
|
||||
from app.models import AuditLog, DebugScreenshot, DebugSession, LogEvent, User
|
||||
@@ -508,28 +508,83 @@ def test_a_half_sent_session_keeps_its_screenshots_a_finished_one_lets_them_go(d
|
||||
assert client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}").status_code == 200
|
||||
|
||||
|
||||
def test_a_send_under_way_holds_the_draft_and_the_screenshots_without_blocking_stop(db, recording):
|
||||
def _age_the_claim(session, hours=1):
|
||||
"""The claim a send holds, as a request that died an hour ago left it."""
|
||||
state = debugsession.delivery(session)
|
||||
stale = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
|
||||
session.draft = {
|
||||
**(session.draft or {}),
|
||||
"delivery": {**state, "sending": {**state["sending"], "at": stale}},
|
||||
}
|
||||
|
||||
|
||||
def test_a_send_under_way_holds_the_draft_and_the_screenshots_without_blocking_stop(
|
||||
gitea_fake, db, recording
|
||||
):
|
||||
client, sid = recording
|
||||
shot = _upload(client, sid, _PNG).json()["screenshot"]["id"]
|
||||
_ready(client, sid)
|
||||
session = db.get(DebugSession, sid)
|
||||
debugsession.claim_send(session)
|
||||
db.commit()
|
||||
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"})
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_sending"
|
||||
assert _upload(client, sid, _PNG).json()["detail"]["reason"] == "issue_sending"
|
||||
# Nor removed: the send names the screenshots by their order, and this would renumber them
|
||||
# under it — even before the issue exists, when nothing else would refuse it yet.
|
||||
r = client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}")
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_sending"
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_sending"
|
||||
assert client.get(f"/api/debug-sessions/{sid}").json()["sending"] is True
|
||||
# Stop is NOT held: the recording is the user's to end while their report is going out.
|
||||
assert client.post(f"/api/debug-sessions/{sid}/stop").status_code == 200
|
||||
# A claim left behind by a request that died is taken over by the next send.
|
||||
debugsession.set_delivery(
|
||||
session,
|
||||
{"sending": (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
# A claim left behind by a request that died is taken over by the next send — which then
|
||||
# actually sends. Without the take-over the report could never leave, and nothing in the UI
|
||||
# would say why.
|
||||
_age_the_claim(session)
|
||||
db.commit()
|
||||
assert (
|
||||
client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"}).status_code
|
||||
== 200
|
||||
)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 200 and r.json()["session"]["issue_url"]
|
||||
assert client.get(f"/api/debug-sessions/{sid}").json()["sending"] is False
|
||||
|
||||
|
||||
def test_a_slow_send_keeps_its_own_claim_alive_and_gives_back_only_that_one(db, recording):
|
||||
"""The lease is renewed by the progress the send reports: a send slower than the lease must not
|
||||
expire while it is still working, and then overwrite whoever took its place."""
|
||||
client, sid = recording
|
||||
session = db.get(DebugSession, sid)
|
||||
token = debugsession.claim_send(session)
|
||||
_age_the_claim(session)
|
||||
assert debugsession.sending(session) is False
|
||||
# One more step reported — proof of life, so the claim stands again.
|
||||
debugsession.set_delivery(session, {"number": 7, "pending": ["server.log"], "links": {}})
|
||||
assert debugsession.sending(session) is True
|
||||
assert debugsession.not_uploaded(session) == ["server.log"]
|
||||
# The claim of a send that took over after ours aged out is not ours to hand back.
|
||||
debugsession.release_send(session, "a-different-send")
|
||||
assert debugsession.sending(session) is True
|
||||
debugsession.release_send(session, token)
|
||||
assert debugsession.sending(session) is False
|
||||
|
||||
|
||||
def test_a_users_uploads_wait_for_each_other_across_their_sessions(db):
|
||||
"""The cap below is the user's across ALL their sessions, but a session's row lock only bounds
|
||||
that session — two uploads to two sessions both read the total before either commits, and both
|
||||
pass. The user's own lock is what makes the cap a cap instead of a guess."""
|
||||
from app.db import SessionLocal
|
||||
|
||||
debugsession.lock_user(db, 4242)
|
||||
other = SessionLocal()
|
||||
try:
|
||||
# A second request for the same user cannot get in to read the total while we hold it...
|
||||
assert other.scalar(select(func.pg_try_advisory_xact_lock(4242))) is False
|
||||
# ...and another user is not queued behind them for it.
|
||||
assert other.scalar(select(func.pg_try_advisory_xact_lock(4243))) is True
|
||||
finally:
|
||||
other.rollback()
|
||||
other.close()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_a_users_screenshots_are_capped_across_their_sessions(recording, monkeypatch):
|
||||
@@ -767,9 +822,11 @@ def gitea_fake(monkeypatch):
|
||||
monkeypatch.setattr(settings, "issues_gitea_url", "https://git.example/")
|
||||
monkeypatch.setattr(settings, "issues_gitea_repo", "o/r")
|
||||
monkeypatch.setattr(settings, "issues_gitea_token", "t0ken")
|
||||
routes._new_issues.reset()
|
||||
for limiter in (routes._new_issues, routes._sends, routes._previews):
|
||||
limiter.reset()
|
||||
yield fake
|
||||
routes._new_issues.reset()
|
||||
for limiter in (routes._new_issues, routes._sends, routes._previews):
|
||||
limiter.reset()
|
||||
|
||||
|
||||
def _ready(client, sid, title="Spinner stays"):
|
||||
@@ -837,6 +894,69 @@ def test_the_claim_is_held_through_every_step_of_a_send(gitea_fake, db, make_use
|
||||
assert held and all(held) # after the issue was created, and after every upload
|
||||
|
||||
|
||||
def test_the_issues_trail_is_written_by_the_step_that_writes_the_link(
|
||||
gitea_fake, db, make_user, as_user
|
||||
):
|
||||
"""An issue whose link is committed without its trail can never get one: the next send reads
|
||||
the link, knows the issue is not new, and records nothing. So the two go in one step."""
|
||||
from app.issues import gitea
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
user = make_user()
|
||||
client = as_user(user)
|
||||
sid = _start(client)["id"]
|
||||
_upload(client, sid, _PNG)
|
||||
_ready(client, sid)
|
||||
session = db.get(DebugSession, sid)
|
||||
debugsession.claim_send(session)
|
||||
db.commit()
|
||||
seen: list[tuple[bool, int]] = []
|
||||
|
||||
def checkpoint() -> None:
|
||||
db.commit()
|
||||
rows = db.scalar(
|
||||
select(func.count()).select_from(AuditLog).where(AuditLog.action == "issue_create")
|
||||
)
|
||||
seen.append((session.issue_url is not None, rows or 0))
|
||||
|
||||
gitea.deliver(
|
||||
db,
|
||||
session,
|
||||
checkpoint=checkpoint,
|
||||
on_created=lambda: routes._record_created(db, user, session),
|
||||
)
|
||||
# Every commit that can be seen with the link can be seen with the trail beside it.
|
||||
assert seen and all(rows == 1 for linked, rows in seen if linked)
|
||||
|
||||
|
||||
def test_a_send_that_cannot_give_its_claim_back_still_reports_its_own_failure(
|
||||
gitea_fake, db, make_user, as_user, monkeypatch, caplog
|
||||
):
|
||||
"""The rescue in `_end_send` is there so a dead transaction cannot replace the news: what the
|
||||
user sees must be why the SEND failed, not why the tidying up did."""
|
||||
user = make_user()
|
||||
client = as_user(user)
|
||||
sid = _start(client)["id"]
|
||||
_upload(client, sid, _PNG)
|
||||
_ready(client, sid)
|
||||
gitea_fake.fail_asset = "client.log"
|
||||
locks = {"n": 0}
|
||||
real_lock = debugsession.lock
|
||||
|
||||
def flaky(db_session, session):
|
||||
locks["n"] += 1
|
||||
if locks["n"] > 1: # the route's own lock works; the one in `_end_send` does not
|
||||
raise RuntimeError("the database went away")
|
||||
return real_lock(db_session, session)
|
||||
|
||||
monkeypatch.setattr(debugsession, "lock", flaky)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_unreachable"
|
||||
events = [getattr(x, "event", "") for x in caplog.records]
|
||||
assert "issues.claim.stuck" in events
|
||||
|
||||
|
||||
def test_a_public_repository_gets_nothing(gitea_fake, db, client, make_user):
|
||||
gitea_fake.private = False
|
||||
_signed_in(client, make_user)
|
||||
@@ -903,6 +1023,48 @@ def test_an_answer_that_is_not_the_api_is_refused_and_the_issue_stays_linked(
|
||||
assert [m for m, p in gitea_fake.calls if p == "/repos/o/r/issues"] == ["POST"]
|
||||
|
||||
|
||||
def test_the_preview_has_a_pace_of_its_own(gitea_fake, db, make_user, as_user):
|
||||
"""It is one authenticated call to the operator's tracker each, with the operator's token."""
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
client = as_user(make_user())
|
||||
sid = _start(client)["id"]
|
||||
monkeypatch_max = routes._previews.max_events
|
||||
routes._previews.max_events = 2
|
||||
try:
|
||||
for _ in range(2):
|
||||
assert (
|
||||
client.post(f"/api/debug-sessions/{sid}/preview", json={"text": "x"}).status_code
|
||||
== 200
|
||||
)
|
||||
r = client.post(f"/api/debug-sessions/{sid}/preview", json={"text": "x"})
|
||||
assert r.status_code == 429 and r.json()["detail"]["reason"] == "preview_rate_limited"
|
||||
finally:
|
||||
routes._previews.max_events = monkeypatch_max
|
||||
|
||||
|
||||
def test_a_retry_that_never_finishes_is_still_paced(gitea_fake, db, make_user, as_user):
|
||||
"""The budget that guards OPENING an issue must never stand between a user and finishing their
|
||||
own report — so the retry has a separate, far looser one, that only a loop reaches."""
|
||||
from app.routes import debug_sessions as routes
|
||||
|
||||
client = as_user(make_user())
|
||||
sid = _start(client)["id"]
|
||||
_upload(client, sid, _PNG)
|
||||
_ready(client, sid)
|
||||
was = routes._sends.max_events
|
||||
routes._sends.max_events = 2
|
||||
try:
|
||||
gitea_fake.fail_asset = "client.log"
|
||||
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 422
|
||||
gitea_fake.fail_asset = "client.log"
|
||||
assert client.post(f"/api/debug-sessions/{sid}/issue").status_code == 422
|
||||
r = client.post(f"/api/debug-sessions/{sid}/issue")
|
||||
assert r.status_code == 429 and r.json()["detail"]["reason"] == "issue_rate_limited"
|
||||
finally:
|
||||
routes._sends.max_events = was
|
||||
|
||||
|
||||
def test_a_redirecting_tracker_is_refused_not_a_crash(gitea_fake, db, make_user, as_user, caplog):
|
||||
gitea_fake.redirect = True
|
||||
admin = as_user(make_user(role="admin"))
|
||||
|
||||
Reference in New Issue
Block a user