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:
2026-09-20 00:51:12 +02:00
parent 134ec9a68e
commit 3f983e6d76
17 changed files with 347 additions and 126 deletions
+59 -10
View File
@@ -300,11 +300,22 @@ def test_a_request_without_the_header_keeps_the_sessions_floor(gated):
assert root.level == logging.INFO
def test_an_erased_account_takes_its_session_elevation_with_it(gated):
until = datetime.now(timezone.utc) + timedelta(minutes=30)
request = contextvars.copy_context()
request.run(logs.set_user, 11, "TRACE", until, "b" * 16)
assert logging.getLogger().level == logs.resolve_level("TRACE")
logs.forget_user(11)
assert logs._elevations == {} and logging.getLogger().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))
session = DebugSession(level="DEBUG", until=now + timedelta(minutes=30))
assert debugsession.level_for(user, session) == ("TRACE", user.diagnostics_until)
# The user's own level, but never past the recording: the process tracks this pair under the
# session, and an expiry outliving it would hold the root logger down after it ended.
assert debugsession.level_for(user, session) == ("TRACE", session.until)
assert debugsession.level_for(User(), session) == ("DEBUG", session.until)
assert debugsession.level_for(User(), None) == ("INFO", None)
@@ -480,14 +491,45 @@ 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):
def test_a_half_sent_session_keeps_its_screenshots_a_finished_one_lets_them_go(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"
session = db.get(DebugSession, sid)
session.issue_url = "https://git.example/o/r/issues/1"
debugsession.set_delivery(session, {"number": 1, "pending": ["server.log"], "links": {}})
db.commit()
# The issue carries it already, and a half-sent one names its screenshots by their order.
# A send that still owes files names its screenshots by their order: removing one renumbers
# what it has left to send.
r = client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "screenshot_already_sent"
# Finished: the issue has its own copy, and ours is the user's to drop.
debugsession.set_delivery(session, {"number": 1, "pending": [], "links": {}})
db.commit()
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):
client, sid = recording
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"
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 409 and r.json()["detail"]["reason"] == "issue_sending"
# 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()},
)
db.commit()
assert (
client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"}).status_code
== 200
)
def test_a_users_screenshots_are_capped_across_their_sessions(recording, monkeypatch):
@@ -814,10 +856,12 @@ def test_a_failed_edit_that_shows_the_screenshots_is_finished_by_the_retry(
_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"]
# Every file is up, but the body does not show the screenshot yet: the send is not finished —
# and nothing is named as an attachment that did not go, because they all did.
session = client.get(f"/api/debug-sessions/{sid}").json()
assert (session["pending_files"], session["images_pending"]) == ([], True)
r = client.post(f"/api/debug-sessions/{sid}/issue")
assert r.status_code == 200 and r.json()["session"]["pending_files"] == []
assert r.status_code == 200 and r.json()["session"]["images_pending"] is False
assert "![screenshot-1.png](https://git.example/a/screenshot-1.png)" in gitea_fake.patched_body
assert len(gitea_fake.uploads()) == 3 # nothing uploaded twice
@@ -837,18 +881,23 @@ 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_a_redirecting_tracker_is_refused_not_a_crash(gitea_fake, db, make_user, as_user):
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"))
r = admin.post("/api/admin/config/test-issues")
with caplog.at_level(logging.WARNING):
r = admin.post("/api/admin/config/test-issues")
assert r.status_code == 422 and r.json()["detail"]["reason"] == "issues_rejected"
# Refused as an ANSWER — only the API's own 2xx is one. A 3xx read as success would be handed to
# the parser instead, and the tracker's address would look merely unparseable.
events = [e for x in caplog.records if (e := getattr(x, "event", "")).startswith("issues.")]
assert events == ["issues.gitea.refused"]
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"]
assert debugsession.not_uploaded(session) == ["a.log"]
def test_a_report_needs_a_title_and_a_wrong_token_says_so(gitea_fake, db, client, make_user):