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
+21 -8
View File
@@ -121,6 +121,7 @@ function ComposerBody({
);
const [reviewing, setReviewing] = useState<Source | null>(null);
const issueLink = useRef<HTMLAnchorElement>(null);
const retryButton = useRef<HTMLButtonElement>(null);
// The log whose review just closed: its Review button takes the focus back.
const reviewed = useRef<Source | null>(null);
const sent = session.issue_url !== null;
@@ -205,14 +206,17 @@ function ComposerBody({
onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session);
notify({ level: "success", message: t("recorder.composer.sentOk") });
// The Retry that was just pressed lives in a banner that this answer unmounts. Hand the focus
// to the link that takes its place, the way every other control-that-goes here does.
window.setTimeout(() => issueLink.current?.focus(), 0);
},
// A failure half-way still created the issue: show what the server now has.
onSettled: () => {
onSettled: async () => {
void qc.invalidateQueries({ queryKey: qk.debugSessions(), exact: true });
void qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
await qc.invalidateQueries({ queryKey: qk.debugSession(session.id), exact: true });
// Whichever way it went, the button that was just pressed is gone: a success unmounts the
// Retry's banner, and a half-failure unmounts Create issue (the refetch above brings back the
// issue_url it created before it died). Either leaves the focus on <body> inside an open
// dialog, so hand it on — to whatever now stands where the pressed control was. AFTER the
// refetch, not in onSuccess/onError: before it lands, neither target is rendered yet.
window.setTimeout(() => (retryButton.current ?? issueLink.current)?.focus(), 0);
},
});
const locked = sent || send.isPending || sending;
@@ -258,10 +262,14 @@ function ComposerBody({
}, [locked]);
const [mode, setMode] = useState<"edit" | "preview">("edit");
// The export is built from the SAVED draft: save what is on screen first.
// The export is built from the SAVED draft: save what is on screen first — except when the
// draft is already frozen (`locked`), where the save is both pointless and refused. While a send
// holds the claim the server 409s `issue_sending`, and that refusal would take the zip with it:
// `GET /export` has no send guard and would have served exactly what the send is uploading, so
// the one tracker-free way out of a stuck send used to fail at the last step (round 4).
const exportZip = useMutation({
mutationFn: async () => {
if (!sent) await save.mutateAsync();
if (!locked) await save.mutateAsync();
return api.debugExport(session.id);
},
meta: { failTitle: "recorder.composer.exportFailed" },
@@ -297,7 +305,11 @@ function ComposerBody({
className={`flex flex-wrap items-center justify-between gap-2 rounded-xl border px-3 py-2 text-sm ${
sending ? "border-accent/40 bg-accent/10" : "border-warn/40 bg-warn/10"
}`}
aria-live="polite"
// `role="status"` rather than `aria-live`: this box is mounted TOGETHER with its first
// message (a half-failed send inserts it already populated), and a live region created
// with its content is unreliably announced — the rule ui/Toaster.tsx states and follows.
// A role announces on insertion, and its implied atomic re-read is right for one line.
role="status"
>
<span>
{sending
@@ -309,6 +321,7 @@ function ComposerBody({
{/* Nothing to retry while it is still going — the button would only be refused. */}
{!sending && (
<Button
ref={retryButton}
variant="outline"
size="sm"
loading={send.isPending}