fix(diagnostics): deep-review round 4 — a claim only its owner may write, and a player that keeps the Tab

Round 4 was targeted at the surfaces rounds 2 and 3 changed. 29 findings raised,
26 survived deduplication, 12 survived three adversarial readings each.

`set_delivery` renewed the claim but never checked it was still ours, while its
sibling `release_send` always did. `expire_on_commit` is off, so the token it
re-stamped came from the caller's own memory, not the row: a send that outlived
its 600 s lease came back, overwrote the take-over send's committed links with a
full-column write, and put its own dead token back. It now takes the token, reads
the row's claim with a targeted scalar (not a refresh, which would discard the
`issue_url` assigned one line earlier) and raises `SendClaimLost` rather than
writing. An issue created by a send that had already lost its claim is logged with
its number — that record is the only one that will exist.

The tracker's settings are read above the pre-call checkpoint instead of inside
`_client`. Reading them there was a SELECT one line after the commit that was
meant to end the transaction, and held it — idle, snapshot pinned — across the
repository check and the create. The comment and the module docstring said
otherwise; measuring it in pg_stat_activity is what settled it. Two more comments
were saying things the code had stopped doing: the `**state` spread does not carry
the claim (`set_delivery` strips and re-stamps it), and react-query does not
debounce the preview — the editor being unmounted is what paces it.

The composer stopped stranding the focus on `<body>`: a send that creates the
issue and then fails on an attachment unmounts the button that was just pressed,
and only the success path handed the focus on. Export no longer saves into a
refusal it does not need — the zip is the way out of a stuck send, and its
pre-save was the one step that could not survive one. The delivery banner
announces through a role instead of an `aria-live` created with its own message.

`historyState` returns a union, so `chipTone` matching on the same keys is checked
by tsc rather than by hope.

PlexPlayer was the only full-screen overlay in the tree with no focus containment
at all — Tab walked out of an opaque player into a page nobody can see. It now
carries the shared trap. Closing that hole exposed the one underneath it:
`useDismiss` registered as a layer that traps the Tab while installing no trap,
so the player stood its own down for its own menus. Measured in a real browser:
30 Tabs with the settings panel open, 9 escapes into the nav rail, and 0 after.
Escape ordering is unchanged — the panel first, then the player.

Three tests that did not hold their line: the advisory lock's call site (deleting
it left the suite green), the trail assertion whose `all()` was vacuous over an
empty filter, and a `layerStack` assertion the stand-in dialog already forced.
All eleven executable fixes mutation-checked red for the right reason; the two
comment corrections have nothing to pin.
This commit is contained in:
2026-09-20 04:30:42 +02:00
parent 2fe58073e4
commit 90fc86d437
12 changed files with 486 additions and 61 deletions
+34 -8
View File
@@ -238,17 +238,43 @@ def release_send(session: DebugSession, token: str) -> None:
_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).
class SendClaimLost(Exception):
"""This send's claim is no longer on the row: it aged out and another send took it over. The
send must stop — what it uploads from here it can no longer record, so a retry would send it
again — and it must not write, because the row now holds the other send's progress."""
The claim is not the caller's to carry. `gitea.deliver` threads ONE snapshot of this dict
def claim_owner(db: Session, session: DebugSession) -> str | None:
"""Who holds the row's claim RIGHT NOW, read from the ROW and not from the object.
`expire_on_commit` is off (app/db.py), so a checkpoint does not re-read: a send that stalled
past its lease still carries its own dead token in memory long after another send took over.
A targeted scalar, not `lock`/`db.refresh` — a refresh here would discard the `issue_url` that
`gitea.deliver` assigns one line before its first progress report, and that link is the one
thing the send must not lose."""
return db.scalar(
select(DebugSession.draft["delivery"]["sending"]["by"].as_string()).where(
DebugSession.id == session.id
)
)
def set_delivery(db: Session, session: DebugSession, state: dict, token: str) -> None:
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it), and renew
the claim while doing it — a send still reporting progress is still alive, so one slower than
the lease cannot expire while it is still running.
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)
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
that lost its lease came back and stomped the take-over send's committed links, then put its
own dead token back on an idle session (deep-review round 4). `release_send` has always made
the same check; this is the other half of it."""
if claim_owner(db, session) != token:
raise SendClaimLost(session.id)
state = {k: v for k, v in state.items() if k != "sending"}
if claim:
state["sending"] = _stamp(claim["by"])
state["sending"] = _stamp(token)
_write_delivery(session, state)
+47 -15
View File
@@ -20,10 +20,12 @@ 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 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.
next — and the first checkpoint comes before the first call, with the tracker's settings read ABOVE
it, so neither a row lock nor a read snapshot nor a pooled connection is held across the calls. The
caller claims the session for the send first (`debugsession.claim_send`) and passes the claim's
token in, which is what keeps a second send, an autosave or a screenshot out of the way; the claim
is renewed by every progress report, and a report whose claim is gone raises rather than writing
over the send that took it. `debugsession.not_uploaded` / `images_pending` read that progress.
"""
from collections.abc import Callable, Iterator
@@ -75,8 +77,10 @@ def target(db: Session) -> dict | None:
@contextmanager
def _client(db: Session) -> Iterator[tuple[httpx.Client, str]]:
url, repo, token = _settings(db)
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
# Takes the settings already read, not the session: reading them HERE opened a transaction one
# line after `deliver` had ended one, and held it across the calls below (deep-review round 4).
url, repo, token = settings
if not (url and repo and token):
raise GiteaError("not_configured")
with httpx.Client(
@@ -144,14 +148,14 @@ def _private_repo(client: httpx.Client, repo: str) -> dict:
def check(db: Session) -> dict:
"""The admin's "test connection": the repository answers, is private and takes issues."""
with _client(db) as (client, repo):
with _client(_settings(db)) as (client, repo):
data = _private_repo(client, repo)
return {"repo": data.get("full_name") or repo, "url": data.get("html_url")}
def preview(db: Session, text: str) -> str:
"""The body as Gitea itself will render it (its markdown flavour, its sanitiser)."""
with _client(db) as (client, repo):
with _client(_settings(db)) as (client, repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm", "Context": repo})
return r.text
@@ -160,12 +164,17 @@ def deliver(
db: Session,
session: DebugSession,
*,
token: str,
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.
`token` is the caller's claim on this session (`debugsession.claim_send`). Every progress report
renews it and checks the row still holds it, so a send that outlived its lease stops here rather
than writing over the one that took over (`debugsession.SendClaimLost`).
`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,
@@ -176,10 +185,15 @@ def deliver(
state = debugsession.delivery(session)
files = bundle.files(db, session)
attachments = [f for f in files if f.name != "issue.md"]
settings = _settings(db)
# 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.
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
# with its snapshot pinned — across the repository check and the create (deep-review round 4
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
checkpoint()
with _client(db) as (client, repo):
with _client(settings) as (client, repo):
_private_repo(client, repo)
if "number" not in state:
if before_create is not None:
@@ -194,9 +208,10 @@ def deliver(
"number",
"html_url",
)
# Built ON the state read at the start, not in place of it: the caller's claim on this
# send lives there, and replacing it would let a draft or a screenshot change the issue
# under the uploads that follow.
# Built ON the state read at the start so nothing already in it is dropped. The claim
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
# any `sending` passed to it and stamps the row's own from `token` (round 4 — removing
# the spread left the whole suite green, because the claim never travelled here).
state = {
**state,
"number": created["number"],
@@ -204,7 +219,24 @@ def deliver(
"links": {},
}
session.issue_url = created["html_url"]
debugsession.set_delivery(session, state)
try:
debugsession.set_delivery(db, session, state, token)
except debugsession.SendClaimLost:
# The one step where losing the claim costs something irreversible: the issue is
# already open on the tracker and this is what would have recorded its link. We
# still must not write — another send owns the row and is opening its own issue —
# so say which issue was left behind, because nothing else will.
# noqa TRY400: the traceback is one SendClaimLost carrying the session id and
# nothing else; the issue number below is the whole news.
log.error( # noqa: TRY400
"an issue was created by a send that had already lost its claim",
extra=logs.event(
"issues.gitea.orphaned",
number=created["number"],
session_id=session.id,
),
)
raise
if on_created is not None:
on_created()
checkpoint()
@@ -231,13 +263,13 @@ def deliver(
"pending": [n for n in state["pending"] if n != item.name],
"links": {**state["links"], item.name: asset["browser_download_url"]},
}
debugsession.set_delivery(session, state)
debugsession.set_delivery(db, session, state, token)
checkpoint()
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}"
_call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body})
debugsession.set_delivery(session, {**state, "images_shown": True})
debugsession.set_delivery(db, session, {**state, "images_shown": True}, token)
checkpoint()
return session.issue_url or ""
+11 -1
View File
@@ -195,7 +195,11 @@ _new_issues = RateLimiter(max_events=5, window_seconds=3600)
# 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.
# The composer's toggle fires it once per switch INTO preview — the editor is the other branch, so
# no keystroke can change the query key while the preview is up — and react-query's cache makes an
# unchanged draft free. (It does NOT debounce, whatever this comment used to say: a key that changes
# per keystroke is one fetch per keystroke, measured. Showing the preview BESIDE the fields would
# need a real debounce first.) This bounds what a loop can aim at the tracker.
_previews = RateLimiter(max_events=60, window_seconds=60)
@@ -238,10 +242,16 @@ def send_issue(
gitea.deliver(
db,
session,
token=token,
checkpoint=db.commit,
before_create=count_new_issue,
on_created=lambda: _record_created(db, user, session),
)
except debugsession.SendClaimLost:
# This send outlived its lease and another one took the session over while it was still
# talking to the tracker. It stopped rather than writing over that one's progress — and a
# send IS under way, just not ours, which is what the caller needs to hear.
raise refused(409, "issue_sending") from None
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.
+55 -18
View File
@@ -496,18 +496,24 @@ def test_a_half_sent_session_keeps_its_screenshots_a_finished_one_lets_them_go(d
shot = _upload(client, sid, _PNG).json()["screenshot"]["id"]
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": {}})
_progress(session, {"number": 1, "pending": ["server.log"], "links": {}})
db.commit()
# 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": {}})
_progress(session, {"number": 1, "pending": [], "links": {}})
db.commit()
assert client.delete(f"/api/debug-sessions/{sid}/screenshots/{shot}").status_code == 200
def _progress(session, state):
"""The delivery a finished-or-abandoned send left on the row, with no claim standing on it.
`set_delivery` is the live send's writer and refuses without one, which is the point of it."""
session.draft = {**(session.draft or {}), "delivery": state}
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)
@@ -556,31 +562,50 @@ def test_a_slow_send_keeps_its_own_claim_alive_and_gives_back_only_that_one(db,
session = db.get(DebugSession, sid)
token = debugsession.claim_send(session)
_age_the_claim(session)
db.commit()
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": {}})
debugsession.set_delivery(
db, session, {"number": 7, "pending": ["server.log"], "links": {}}, token
)
db.commit()
assert debugsession.sending(session) is True
assert debugsession.not_uploaded(session) == ["server.log"]
# And once a send that aged out HAS been taken over, the old one writes nothing: its progress
# report is a whole-column overwrite, so without this it put its own dead token back and took
# the new send's committed links with it. `expire_on_commit` is off, so the old send still has
# its own token in memory — only the row can say whose claim it is.
taken = debugsession.claim_send(session)
db.commit()
with pytest.raises(debugsession.SendClaimLost):
debugsession.set_delivery(db, session, {"number": 9, "pending": [], "links": {}}, token)
assert debugsession.delivery(session)["number"] == 7
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 True
debugsession.release_send(session, taken)
assert debugsession.sending(session) is False
def test_a_users_uploads_wait_for_each_other_across_their_sessions(db):
def test_a_users_uploads_wait_for_each_other_across_their_sessions(db, recording):
"""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."""
pass. The user's own lock is what makes the cap a cap instead of a guess.
Driven through `add_screenshot`, not through `lock_user`: the primitive works whether or not
the upload calls it, so a test that called it here stayed green with the call site deleted."""
from app.db import SessionLocal
debugsession.lock_user(db, 4242)
client, sid = recording
session = db.get(DebugSession, sid)
debugsession.add_screenshot(db, session, _PNG) # not committed: the lock is ours until it is
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
# A second upload by this user cannot get in to read the total while this one holds it...
assert other.scalar(select(func.pg_try_advisory_xact_lock(session.user_id))) 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
assert other.scalar(select(func.pg_try_advisory_xact_lock(session.user_id + 1))) is True
finally:
other.rollback()
other.close()
@@ -883,15 +908,23 @@ def test_the_claim_is_held_through_every_step_of_a_send(gitea_fake, db, make_use
_upload(client, sid, _PNG)
_ready(client, sid)
session = db.get(DebugSession, sid)
debugsession.claim_send(session)
token = debugsession.claim_send(session)
db.commit()
held: list[bool] = []
held: list[tuple[bool, int]] = []
gitea.deliver(
db,
session,
checkpoint=lambda: (db.commit(), held.append(debugsession.sending(session)))[0],
token=token,
checkpoint=lambda: (
db.commit(),
held.append((debugsession.sending(session), len(gitea_fake.calls))),
)[0],
)
assert held and all(held) # after the issue was created, and after every upload
# The first checkpoint comes before the tracker is touched at all — that is what keeps the
# bundle's read snapshot, and its connection, out of the calls that follow.
assert held[0] == (True, 0)
# ...and the claim outlasts the issue's creation and every upload after it.
assert len(held) > 1 and all(alive for alive, _ in held)
def test_the_issues_trail_is_written_by_the_step_that_writes_the_link(
@@ -908,7 +941,7 @@ def test_the_issues_trail_is_written_by_the_step_that_writes_the_link(
_upload(client, sid, _PNG)
_ready(client, sid)
session = db.get(DebugSession, sid)
debugsession.claim_send(session)
token = debugsession.claim_send(session)
db.commit()
seen: list[tuple[bool, int]] = []
@@ -922,11 +955,15 @@ def test_the_issues_trail_is_written_by_the_step_that_writes_the_link(
gitea.deliver(
db,
session,
token=token,
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)
# Every commit that can be seen with the link can be seen with the trail beside it — and there
# has to BE one, or the filter selects nothing and `all()` is vacuously true. This assertion
# used to go green over a `deliver` that never wrote the link at all.
linked_rows = [rows for linked, rows in seen if linked]
assert linked_rows and all(rows == 1 for rows in linked_rows)
def test_a_send_that_cannot_give_its_claim_back_still_reports_its_own_failure(