Repository navigation
BUZZ-271: Bound live-session revalidation - #8241
bradseiler wants to merge 20 commits into
Conversation
## Summary A workflow whose saved owner is banned from the community can no longer create runs through any trigger: webhook, channel-event, cron, or signed command trigger. Every one of those doors already calls `WorkflowEngine::check_owner_authority`, which re-checks the owner's channel role. That gate now also reads the owner's authoritative restriction state. A ban denies the run, and a failed lookup fails closed. Unbanning restores admission. On the webhook door the denial is the same generic 404 as before, and no run is queued. On the event and cron paths, a ban denial is logged at WARN at most once per workflow every ten minutes, and at DEBUG in between. A failed lookup and every other denial still warn every time. The handler-local ban lookup in `api/bridge.rs` is removed because the shared gate covers it. This is a point-in-time check: a ban committed after the lookup can race with run creation. It does not cancel existing runs. Community timeouts do not block runs in this change. This PR also carries the shared mesh receive fix needed for independent validation (see Shared carry). ### Related BUZZ-272. - Invitations: [#8237](#8237). - Route coverage: [#8239](#8239). - Live-session revalidation: [#8241](#8241). - Staging runbook: [#8235](#8235). ### Testing Postgres lane: - `on_event_denies_run_while_owner_banned`: a banned owner's channel-event trigger creates no run, and after unban it creates one. - `cron_tick_skips_banned_owners_without_consuming_the_claim`: one tick skips a banned owner and an agent whose human owner is banned, while an unbanned workflow on the same tick still fires. After unban, a tick for the same scheduled instant fires both, so the denial did not consume their scheduled-fire claim. - `on_event_denies_run_when_restriction_lookup_fails`: the event path fails closed when the restriction read errors, and fires once the read succeeds. - `webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure`: the HTTP door returns the same generic 404 for a wrong secret, a banned owner, a forbidden role and a failed restriction lookup, with no run created. It also covers another tenant's allowed control and unban. Unit lane: - `ban_denial_warns_once_per_interval_while_other_denials_always_warn`. Results: - At `81c367f48`, CI passed: Unit Tests 572/572, PostgreSQL Tests 1169/1169 and Relay E2E. A local-relay E2E also passed: - unbanned webhook: 202 and one run; - banned webhook: generic 404 and no run; - banned event trigger: no run; - after unban: one run. - At `72c8d8bbc` (merged with main), CI passed: Unit Tests (including all 171 buzz-workflow unit tests), PostgreSQL Tests 1227/1227 (all four ban tests listed by name), and both `Build (linux/amd64|arm64)` image jobs, which build with `--locked`. A Blox run at the same head also passed `cargo metadata --locked`, fmt and clippy, and the buzz-workflow unit tests passed there with no database reachable. - Not covered: the signed trigger has no dedicated test. It calls the same gate, which was confirmed by reading the code. ### Shared carry #8237, #8239, #8241 and #8242 each carry the same mesh-receive fix, boot and scraper regressions, and push-lease fixture correction. Whichever lands first owns them, and the others drop them on their next main merge. ### What got simpler The ban check now lives in the one gate every run-creation door already shares, so there is one fewer handler-local branch. Cron, event and signed triggers get the same protection without per-door code. The cron loop body is now `cron_tick(now)`, so a test can drive one tick without the 60-second sleep. buzz-workflow's infra-free unit tests ran in no CI lane. `just test-unit` and its cargo fallback in `scripts/run-tests.sh` now run them. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: seiler <seiler@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
541284c to
c0c22bb
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Automated review at exact head c0c22bbf808eaed1042d556867415ec813237c2a (base 425d89b4d412adce216e5f58bc985743b0da311b). Requesting changes for two blocking findings, both about how the scan behaves when the writer is slow.
IMPORTANT (correctness): a timed-out batch keeps its writer connection busy for up to two minutes. revalidate_live_authorization_sessions (state.rs:979) wraps each batch in a 1-second tokio::time::timeout, but live_authorization_state_batch (live_authorization.rs:71) runs on an ordinary pooled writer connection with no statement-level timeout. When the wrapper fires, the future is dropped and the PoolConnection goes back through sqlx-core 0.9's Floating::return_to_pool. That runs an untimed ping() (pool/connection.rs:314), which has to wait for the abandoned statement to finish first. The writer's session statement_timeout defaults to 2 minutes (buzz-db/src/runtime/mod.rs:750). So a slow writer turns each timed-out batch into a connection held for up to 120 s, and the next scan 5 s later starts four more. The documented "at most 4 queries at a time" bound covers the futures, not the database work. Under the conditions this timeout exists for, the scan can starve the serving writer pool. Fix: bound the statement on the server, e.g. run the batch in a transaction with SET LOCAL statement_timeout just under the wrapper deadline. Also make a checkout that is timed out or cancelled by the scan close its connection (with a bounded close) instead of returning it to the pool.
IMPORTANT (design): closing sessions on a failed lookup makes writer saturation worse. Once a target's last good check is more than 10 s old, the failure branch (state.rs:1141) closes every session it holds, and it does so for pool timeouts and query timeouts as well as hard outages. While the writer is down, no new ban or removal can commit. So in an outage, failing closed only protects against a revocation committed in the few seconds before the outage whose Redis fan-out was also missed. The cost is that every root and audio socket on every pod closes together. In the saturation case the loop compounds itself: scan timeouts close sockets without jitter, the clients reconnect together, and each admission now makes an extra writer read (the captured-owner membership read in final_admission_check). That puts more load on the writer that was already the problem. ARCHITECTURE.md acknowledges the reconnect herd but doesn't mitigate it. Please either keep sessions through lookup failures and apply the verdict on the next successful snapshot, or limit fail-closed to a much longer outage window and jitter the closes. Whichever you pick, state the security/availability tradeoff in ARCHITECTURE.md.
MINOR (non-blocking):
relayAuthPolicy.ts:34copies four relay error strings intoSERVER_UNAVAILABLE_AUTH_REPLIES, with no shared constant or contract test. If a relay message is reworded, the desktop silently goes back to capped retries.- The
(None, true)invariant violation infinal_admission_check(auth.rs:323) returns "internal error checking relay membership". The desktop now retries that message forever, even though here it signals a server bug rather than an outage. admission_db_error_class(auth.rs:236) is identical tolive_authorization_db_error_class(state.rs:1218); keep one.- The
useKnownAgentPubkeys.test.mjsobserver-race fix doesn't touch this feature and would be cleaner in its own PR. ARCHITECTURE.md:283records workstation timings (70 ms, 129 ms) that will go stale. The batch, concurrency, and deadline bounds are the durable facts.
Checked and fine: users and community_bans are keyed by (community_id, pubkey), so batch rows are unique per target and the incomplete-result guard won't misfire. Scans can't overlap (MissedTickBehavior::Skip plus a sequential await). An admitted owner-derived session always has its captured owner equal to the stored owner, because owner_link_denial refuses admission when the link doesn't land. Every predicate is community-scoped. The test database URL edits are already on main. We ran the PR's live_revalidation E2E at this head against an isolated Postgres and Redis. Member and NIP-OA owner-linked root and audio sockets closed with 1008 access revoked about 4.5 s after database-only bans, and the clear bystander stayed usable. With the ban table made unavailable, the remaining sockets closed with authorization unavailable about 10 s later.
Units 2-5 of the 23-30 Oct desktop Playwright flake audit. All four are test-only. origin/main (1eb67bb, now including #8680) hasn't touched these specs since fae3f03. Of the open PRs, #8622 adds an unrelated test at the end of navigation.spec, and #7812 (stale since 10-06) adds tests elsewhere in three of them. #8680 didn't touch them, but it carried navigation:472 mode A's product half (eb88a98), which is not part of this commit. The code confirms each cause, so none is dropped. navigation.spec.ts:472, "composer Buzz chip labels wrap without orphaning their icons" Runs: 11 jobs in the window, 6 after the earlier fixes, and 4 of the 7 main runs that carry every fix. Run 40970053997 (Smoke shard 7) failed all three attempts. Mode A: jobs 122971493553, 123066839967, 123572467962, 123819180597 and retry 2 of 123784825042. Signature: fragmentStep is null at :634. Cause: rows key on renderKey = localKey ?? id. On main, reconcileChannelWindowMessages keeps only pending sends and thread replies from the cache, so a head refetch or live echo after the ack drops the localKey. The key flips and the row remounts, losing the 220 px width the test set in an earlier evaluate. Change: re-land the test half of closed PR #6852, which never merged. Set the width and measure in one expect.poll, and set the width again before each fragment measurement. Mode B: jobs 123573056202, 123784199542, 123784825042 (twice), 123804428893, 123818728963, 123819543461, and 122924164125 (the Infinity variant). Signature: the |tooltipCenter - cursorX| < 1 poll at :667 receives 81.35 or Infinity. In failing attempt 40970053997 the post-send settle (scrollTop 200, 296, 260, 302) took its last step 21 ms or less before focus, and the tooltip read closed 12 ms after it. In the passing retry 40980513346, scrolling had stopped at least 15 ms before focus. Cause: Radix Tooltip 1.2.16 closes when an ancestor of its trigger scrolls (a capture-phase window listener). Reopening by pointer needs a pointermove while hasPointerMoveOpenedRef is false, then the chip provider's 500 ms delay. Any close clears that timer, and only pointerleave resets the flag. useInlineTooltipPosition moves the content only on pointermove and placement. So a scroll after focus can leave the tooltip closed, or open without the fragment offset, and the spec's single mouse.move never recovers it. Change: before focusing the chip, poll until the timeline sits at its floor (scrollHeight - clientHeight - scrollTop <= 1) at the start of one poll, is still there two animation frames later, and its scrollTop hasn't moved. Waiting only for scrollTop to hold for two frames isn't enough: a timeline whose settle hasn't started yet reads as still, and the failing run's last step landed under two frames before focus. The settle's end state is the floor. useVirtualizedBottomSettle scrolls only when Virtua's ResizeObserver reports a layout change, and after the send and the 220 px wrap the timeline sits short of the floor until the settle catches up. Focusing the chip and moving the pointer over it don't change layout. The send's ack does. It removes the row's "Sending…" status, and the confirmed row can lose its header, since a pending row is never shown as a continuation (MessageRow.tsx:167). The send's bottom intent is still armed, so that re-pins the timeline. So before the floor poll, wait for the sent row's message-send-status to reach count 0. If the ack has already landed, that wait is a no-op. The standalone toBeVisible after focus is gone. positionOverFragment asserts data-state matches /open/ instead, because a closing tooltip stays visible through its exit animation. No retry. Wrapping the measure, move and check in toPass, leaving and re-entering the chip on each attempt, would also pass, but it retries around the race instead of removing it. The ack and floor waits close the scroll window. The only other way to scroll under the tooltip was a remount that drops the row's 220 px width (mode A), and #8680's retainAcknowledgedLocalKeys, on this branch's base (1eb67bb), keeps the row's key. So positionOverFragment is a single pass, as on main, plus mode A's width re-set: set the width, measure, move, assert open, then poll the centre with the default timeouts. huddle-transcription.spec.ts:637, "animates the responding agent with the shared speaker ring" Runs: 9 jobs, always Smoke shard 4 attempt 1. Main: 39896948198, 40718743531, 40732061485, 40840949170, 40906253761, 40969965981 and 40983029157. PRs: #8715, #8666. Signature: toBeFocused() at :697 reads inactive for all 14 polls over 5 s. At +3.0 s in the failure video (40983029157, job 123827818084) the caret is in "Message #huddle". Cause: useComposerAutofocus re-runs when `focus` changes. That's focusEnd, which depends on the editor, so it runs again once the editor mounts. It backs off only for input, textarea, select and contentEditable focus, so it takes focus from the focused trigger. Change: wait for message-input to be focused before focusing the trigger. The same spec already does this at :855. scroll-history.spec.ts:1289, "fast middle-page scroll settles with continuous mounted coverage" Runs: 9 jobs in the window. After the fixes: main 40980404335 (Smoke shard 9, job 123819180518), two attempts on #8720, and the brain/native-account-setup branch. Signature: the viewportCoverage poll expects < 100 and receives 200. The failure screenshot shows the timeline at the floor. The 200 px is the bottom spacer (--composer-overlay-height) under the last row. Cause: TimelineMessageList arms useVirtualizedBottomSettle when the channel first positions. Its ResizeObserver then pins to the floor until listenForReaderScroll input or an accepted target cancels it. handleScroll deliberately doesn't cancel, and a prepend delta doesn't settle, so the intent survives the prepend and the spec's scrollTop writes. Virtua's re-measure after the jump pulls the list back down. Change: before the fast scroll, hover the timeline and wheel up 120 px. Then poll until it is more than 32 px from the floor (the timeline's at-bottom band) and scrollTop holds for two frames, so the writes stay the last scroll. custom-emoji.spec.ts:370, "reacting with a custom emoji renders via the loopback media proxy" Runs: main 40787609859 (10-28), main 40983029157 (Smoke shard 3, a run with every fix), and #8241 (agent and auth files only). Signature: the poll at :453 expects the inline "Add reaction" opacity "0" and receives "1". The error context marks button "Open reactions" [active]. Cause: the non-modal Radix Popover returns focus to its trigger when the picker closes. FocusScope does that in a setTimeout(0) after the content unmounts, and the content unmounts after its 100 ms exit animation. That can land after the spec's blur. The action bar is hidden by opacity only, so the trigger takes focus, and group-focus-within/message keeps the button visible. Change: wait for the "Open reactions" trigger to be focused, then blur. The note suggested waiting for em-emoji-picker to reach count 0. That still leaves the setTimeout window open. Focus reaches the trigger only after unmount, so this wait covers both. Verification Not run locally: the Flake Check lane will repeat the touched specs. pnpm typecheck and pnpm check pass. biome's 3 warnings are in untouched files. An ad hoc tsc ran over the four specs, using the desktop tsconfig with only those files, and over HEAD's copies of them. Both gave the same error counts: navigation 7, huddle 17, scroll-history 0, custom-emoji 2. The messages are identical once line numbers are stripped, and all are pre-existing: no Node types, findLast against the ES2022 lib, and the leadingBounds narrowing at navigation:576. With the floor wait in place and the retry dropped, navigation still gives the same 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…8743) ## Summary Units 2–5 of the 23–30 Oct desktop Playwright flake audit: four E2E specs that raced scroll settles and focus. All four fixes are test-only. This is one of six PRs split from the reviewed `flakey-tests` branch (siblings below). ### `navigation.spec.ts:472` "composer Buzz chip labels wrap without orphaning their icons" 11 jobs failed in the window, 6 of them after the earlier fixes, including 4 of the 7 main runs that carry every fix. Run 40970053997 (Smoke shard 7) failed all three attempts. - **Mode A** (jobs 122971493553, 123066839967, 123572467962, 123819180597 and retry 2 of 123784825042): `fragmentStep` is null at :634. Rows key on `renderKey = localKey ?? id`, and a head refetch or live echo after the ack drops the `localKey`. The row remounts and loses the 220 px width the test set earlier. **Change:** re-land the test half of closed #6852. Set the width and measure in one `expect.poll`, and set the width again before each fragment measurement. - **Mode B** (jobs 123573056202, 123784199542, 123784825042 twice, 123804428893, 123818728963, 123819543461, and 122924164125): the tooltip-centre poll at :667 receives 81.35 or Infinity. Radix Tooltip closes when an ancestor of its trigger scrolls. In the failing attempt, the post-send settle took its last step 21 ms or less before focus. **Change:** wait for the sent row's `message-send-status` to clear. Then poll until the timeline sits at its floor and holds there for two animation frames, and only then focus the chip. `positionOverFragment` asserts `data-state` is open. There is no retry loop around the race. ### `huddle-transcription.spec.ts:637` "animates the responding agent with the shared speaker ring" - **Runs:** 9 jobs, always Smoke shard 4, attempt 1. On main: 39896948198, 40718743531, 40732061485, 40840949170, 40906253761, 40969965981 and 40983029157. On PRs: #8715 and #8666. `toBeFocused()` at :697 reads inactive. - **Cause:** `useComposerAutofocus` runs again once the editor mounts and takes focus from the focused trigger. - **Change:** wait for `message-input` to be focused before focusing the trigger, as the same spec already does at :855. ### `scroll-history.spec.ts:1289` "fast middle-page scroll settles with continuous mounted coverage" - **Runs:** 9 jobs. After the fixes: main 40980404335 (job 123819180518) and two attempts on #8720. The `viewportCoverage` poll expects < 100 and receives 200, the bottom spacer. - **Cause:** the bottom-settle intent armed when the channel first positions survives the prepend and the spec's `scrollTop` writes. Virtua's re-measure then pulls the list back to the floor. - **Change:** hover the timeline and wheel up 120 px first, since reader input cancels the intent. Then poll until the timeline is more than 32 px from the floor and steady for two frames. - The commit message calls the cancelling listener `listenForReaderScroll`, a name introduced by sibling #8742. On `main`, the same wheel listener is inline in `useVirtualizedBottomSettle`, so this fix doesn't depend on that PR. ### `custom-emoji.spec.ts:370` "reacting with a custom emoji renders via the loopback media proxy" - **Runs:** main 40787609859 and 40983029157 (Smoke shard 3), and #8241. The inline "Add reaction" opacity reads "1", not "0". - **Cause:** the non-modal Radix Popover returns focus to its trigger in a `setTimeout(0)` after its 100 ms exit animation. That can land after the spec's blur. - **Change:** wait for the "Open reactions" trigger to be focused, then blur. ### Related issue None found. Sibling #8744 also edits `navigation.spec.ts`, in a separate hunk. The two merge cleanly in either order. #8622 adds an unrelated test at the end of `navigation.spec.ts`, and #7812 (stale since 10-06) adds tests elsewhere in three of these files. ### Testing Not run locally, because Playwright bogs this machine down. Flake Check will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass. An ad hoc `tsc` run over the four specs found the same pre-existing errors as on their `main` copies (navigation 7, huddle 17, scroll-history 0, custom-emoji 2). Pushed with `git push --no-verify`, because the pre-push suites fail when the laptop is under load. ### Readiness An agent review of the combined `flakey-tests` branch (b432453af) found no blockers. `buzz-review-completed` is not added, because the human test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet. ### Siblings - #8739 ci: Hermit fetch deadline and sccache fallback - #8740 test(relay): thread_window aux-page hold and `api::media` Postgres tests - #8741 test(mobile): RunnerTests title fade and alert wait - #8742 fix(desktop): deep-linked message hold (product change) - #8744 test(desktop): five settle classes and remote-owned-mentions 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
message-target-highlight.spec.ts:79 is the most frequent unfixed desktop flake. It failed in all 7 main runs whose SHA carries every earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899, 40980404335, 40980513346, 40983029157), and in 26 jobs since those fixes landed, on main and on PRs #8235, #8239, #8241, #8666, #8680, attempts failed. Only the two cases that pick mounted[2] fail: "same channel, row loaded off screen" (15 jobs) and "other channel, row further up" (11 jobs). The target scrolls in and flashes, then the timeline lands on the newest message, so `stillOnScreen` at :179 reads false (twice it was `flashedOnScreen` at :153). Cause The triage note blamed the thread panel opening. The failure videos from run 40983029157 (Smoke shard 6) show that is a precondition, not the trigger: - Other channel: the panel opens at +0.36 s, the target flashes at +0.7 s, the older page prepends by +1.04 s, the target is still on screen at +1.47 s, and the timeline sits at the exact floor by +1.97 s. - Same channel: the panel and the flash land in the same frame. The snap follows about 0.5 s later, with the load-older spinner still showing. The panel opening never moved the target. A later floor report did: TimelineMessageList's onScroll reporting within 32 px of the bottom, during or after the older-page load that only a mounted[2] target sets off. onVirtualizerAtBottomStateChange(true) reset the anchor to at-bottom and set virtualizerAtBottomRef. With the panel open, the split-panel settle, the viewport-resize settle or the next append settle then pinned the timeline to the floor. The exact-floor landing points at our own settleAtBottom rather than restored scroll. Which Virtua layout pass emits the floor report is still unidentified. Change Accepting a virtual route target now sets targetHoldRef in useAnchoredScroll. Only the two targetMessageId paths set it, on first mount and in the later target effect, by passing holdTarget to scrollToMessage. The public scrollToMessage, which MessageTimeline's oldest-unread pill and find-in-channel stepping call, doesn't hold. It also replaces an earlier hold, so a find jump after a deep link follows the floor as on main. While the hold is set: - shouldSettleForSplitPanel and shouldSettleVirtualizedViewportResize return false; - a floor report still updates isAtBottom and the new-message count, but keeps the message anchor, so live appends don't settle either; - a commit that changes the newest message sets isAtBottom false, clears virtualizerAtBottomRef and adds the rows after the previous newest one to the new-message count. Virtua reports bottom state only from scroll events, and an append below the viewport emits none. Without this, a floor report left isAtBottom true, so MessageTimeline hid the Jump to latest / N new pill while rows piled up out of sight (Review-Proven Rule 6). A realistic trigger is a mention or search hit on one of the last few messages in a busy channel: scrollToIndex clamps at the floor and emits that report. Clearing virtualizerAtBottomRef means a later release doesn't snap past the new rows either. The hold only acts on an arrival, so a reader at the floor with nothing new sees no pill. Counting rows rather than the length change matters: a cold target is merged in older than the head window, and the history page that fills the gap keeps both ends of the list. classifyTimelineMessageDelta calls that an append, but nothing arrived below, so it raises no pill. Neither does deleting the newest message; - viewport reflow re-centres the target through virtualScrollToMessage, from the viewport-resize observer and when the split panel opens or closes. Rows rewrap around the target without a scroll event, so the target can drift, and a floor report from before the reflow can keep isAtBottom true while the newest rows drop below the fold. The re-centring scroll refreshes the bottom state. Virtua's scrollToIndex cancels any earlier one still in flight, so a burst of resizes only restarts its measure loop. The split-panel call fires only on a transition, not on mount or when the virtualizer API registers. The hold is released by reader scroll input (a wheel, a touch drag, a pointerdown on the scroller itself, a scroll key aimed at it), Jump to latest, the reader's own send, and a channel change. The input listeners are the ones that already cancel bottom intent in useVirtualizedBottomSettle, now shared as listenForReaderScroll. A release at the floor restores the at-bottom anchor, so the timeline follows live messages again. onTargetReached does not release it. The artifacts never show the panel pushing the target off screen, so skipping the settle is the fix aimed at the CI failure. The re-centring keeps the target's place and the pill right after a reflow. Known limits: - Programmatic scrolls (focus moves, scrollTop writes) don't release the hold. A find or unread-pill jump replaces it. A resize after such a scroll (Tab focus into a lower row, say) re-centres the target, away from the focused row. - ChannelPane's composer-height settle still calls scrollToBottom within 32 px of the floor, which releases the hold. - Live messages that arrive while the hold is set near the floor are not followed until reader input, Jump to latest or the reader's own send. The pill shows them, with their count. - Under the hold, only rows after the previous newest message count. A live message that sorts just ahead of it (a same-second timestamp) raises no pill until a later row lands. Unit before/after useAnchoredScroll.lifecycle.test.mjs replays the CI sequence: accept a route target, clear it, open the split panel, report the floor, resize the viewport and append a live message. It asserts no bottom settle, and that the panel opening and the resize each re-centre the target. Then it asserts the append shows the pill (isAtBottom false, one new message). A scrollbar pointerdown after that releases the hold, and a resize neither settles nor re-centres. One case per release path asserts the resize settle resumes and the re-centring stops. One of them is a find jump: a public scrollToMessage on a held timeline, then a floor report. A descendant pointerdown (row interaction) keeps the hold, and a resize still re-centres. Reader input at the floor resumes following a live append. Under a hold, a history page filling the gap below the target, and then deleting the newest message, leave isAtBottom true and the count at 0. A commit that adds more history and one live row counts 1. The split-panel and viewport-resize policy tables gain a held row. Mutations over the lifecycle file and both policy tables (29 tests, all pass intact): - hold never set on accept (the old behaviour): 12 fail, including the CI-shaped hold case - split-panel settle ignores the hold: 13 fail - viewport-resize settle ignores the hold: 13 fail - floor report resets a held anchor: 12 fail (the reset anchor is never re-centred) - reader input does not release: 6 fail - Jump to latest, own send, channel change don't release: 1 each - release skips the at-bottom resync: 1 fails - a held append doesn't raise the pill: 2 fail - a held append leaves virtualizerAtBottomRef set: 1 fails - every scrollToMessage holds (this commit's first draft): 1 fails - a public jump keeps an earlier hold: 1 fails - the held-append branch as first drafted (any non-prepend growth, counted by length): 1 fails, the gap-fill case - a newest-id gate but a length-delta count: 1 fails - a missing previous newest counts every row: 1 fails - no rowsBelow > 0 guard: 1 fails - rows counted from the previous newest, off by one: 2 fail - no split-panel re-centre: 12 fail - no viewport-resize re-centre: 12 fail - re-centre ignores the hold: 1 fails pnpm typecheck and pnpm check pass. All 46 tests in the files that import the touched modules pass. Only hosted CI can show Playwright was not run locally. Whether the spec now holds in Smoke shard 6 and in Flake Check's repeats is unverified, and so is the unidentified Virtua floor report. scroll-history.spec.ts and the other timeline specs need the full desktop smoke run to show the hold doesn't regress bottom-follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
## Summary A product fix for the most frequent unfixed desktop E2E flake, `message-target-highlight.spec.ts:79`. A deep-linked message now holds its place against bottom settles until the reader scrolls. This is one of six PRs split from the reviewed `flakey-tests` branch (siblings below). - **Runs:** it failed in all 7 main runs whose SHA carries every earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899, 40980404335, 40980513346, 40983029157). It also failed in 26 jobs since those fixes landed, on main and on PRs #8235, #8239, #8241, #8666 and #8680. Only the two cases that pick `mounted[2]` fail: "same channel, row loaded off screen" (15 jobs) and "other channel, row further up" (11 jobs). The target scrolls in and flashes, then the timeline lands on the newest message. `stillOnScreen` at :179 reads false (twice it was `flashedOnScreen` at :153). ### Cause The failure videos (run 40983029157, Smoke shard 6) show that the thread panel opening is a precondition, not the trigger. A later floor report moved the target: `TimelineMessageList`'s `onScroll` reported within 32 px of the bottom, during or after the older-page load that only a `mounted[2]` target sets off. `onVirtualizerAtBottomStateChange(true)` reset the anchor to at-bottom. Then the split-panel settle, the viewport-resize settle or the next append settle pinned the timeline to the floor. Which Virtua layout pass emits the floor report is still unidentified. ### Change Accepting a virtual route target sets `targetHoldRef` in `useAnchoredScroll`. Only the two `targetMessageId` paths set it, via `holdTarget`. The public `scrollToMessage`, which the oldest-unread pill and find-in-channel use, doesn't hold, and it replaces any earlier hold. While the hold is set: - The split-panel and viewport-resize settles are skipped. - A floor report still updates `isAtBottom` and the new-message count, but keeps the message anchor, so live appends don't settle either. - A commit that changes the newest message sets `isAtBottom` false and clears `virtualizerAtBottomRef`. It adds the rows after the previous newest one to the new-message count. So the "Jump to latest" / "N new messages" pill still appears for rows below the fold (Review-Proven Rule 6). A history page that fills the gap below a cold target raises no pill, and neither does deleting the newest message. - Viewport reflow re-centres the target through `virtualScrollToMessage`. It runs from the resize observer and when the split panel opens or closes. The hold is released by reader scroll input: a wheel, a touch drag, a pointerdown on the scroller itself, or a scroll key. Those listeners already cancel bottom intent in `useVirtualizedBottomSettle`, and this PR shares them as `listenForReaderScroll`. Jump to latest, the reader's own send and a channel change also release it. A release at the floor restores the at-bottom anchor, so the timeline follows live messages again. `onTargetReached` does not release the hold. ### Known limits - Programmatic scrolls (focus moves, `scrollTop` writes) don't release the hold, but a find or unread-pill jump replaces it. A resize after such a scroll re-centres the target. - ChannelPane's composer-height settle still calls `scrollToBottom` within 32 px of the floor, which releases the hold. - Live messages that arrive under a hold near the floor aren't followed until reader input, Jump to latest or the reader's own send. The pill shows them, with their count. - Under the hold, only rows after the previous newest message count. A same-second row that sorts just ahead of it raises no pill until a later row lands. - Agent review raised two optional gaps, and scrolling recovers from both: - If the previous newest message is deleted and a new row arrives in the same deferred commit, 0 rows are counted. - A thread reply sent with "also send to channel" doesn't arm the bottom intent, so it shows as "1 new message" instead of being followed. ### Related issue None found. ### Testing `useAnchoredScroll.lifecycle.test.mjs` replays the CI sequence: accept a route target, clear it, open the split panel, report the floor, resize and append a live message. It asserts no bottom settle, a re-centre on the panel opening and on the resize, and the pill on the append. Further cases cover: - each release path; - a find jump on a held timeline; - a descendant pointerdown keeping the hold; - a gap-fill page and deleting the newest message, neither of which raises the pill. The split-panel and viewport-resize policy tables each gain a held row. Mutations were run over the lifecycle file and both policy tables (29 tests, all pass intact). Each of the 20 mutations fails at least one test: | Mutation | Tests that fail | |---|---| | Hold never set (the old behaviour) | 12 | | Split-panel settle ignores the hold | 13 | | Viewport-resize settle ignores the hold | 13 | | Floor report resets a held anchor | 12 | | No split-panel re-centre | 12 | | No viewport-resize re-centre | 12 | | Reader input doesn't release the hold | 6 | | Held append raises no pill | 2 | The commit message lists the other 12, each failing 1 or 2 tests. `pnpm typecheck` and `pnpm check` pass. All 46 tests in the files that import the touched modules pass. Only hosted CI can show whether the spec now holds in Smoke shard 6 and in Flake Check's repeats, because Playwright was not run locally. It also has to show that `scroll-history.spec.ts` and the other timeline specs keep bottom-follow working. Pushed with `git push --no-verify`, because the pre-push suites fail when the laptop is under load. ### Readiness and human check An agent review of the combined `flakey-tests` branch (b432453af) found no blockers. This changes desktop app behaviour, so it needs a human in-app check before `buzz-review-completed` is added. Suggested check, in `just dev` against a channel with a few pages of history: 1. Open a message link (a mention or search hit) to a message several screens up, once in the same channel and once from another channel. The message should flash and stay on screen after older history loads, including when the thread panel opens or closes, or the window is resized. 2. With the target still held, have someone post in the channel. The timeline stays put, and the pill reads "1 new message". 3. Wheel-scroll to the bottom, or click Jump to latest. The next message that arrives is followed. ### Siblings - #8739 ci: Hermit fetch deadline and sccache fallback - #8740 test(relay): thread_window aux-page hold and `api::media` Postgres tests - #8741 test(mobile): RunnerTests title fade and alert wait - #8743 test(desktop): four specs racing scroll settles and focus - #8744 test(desktop): five settle classes and remote-owned-mentions 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
c0c22bb to
46fe6ba
Compare
46fe6ba to
fb8a7f5
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Automated review at exact head fb8a7f50dae757241d45d10d18d4648bccbacc61 (base b7d7d61c7c5a19b28ba494e98d8f71301458a853). Requesting changes: the first blocker from the review at c0c22bbf is unchanged after the rebase, and the second is only partly addressed.
IMPORTANT (correctness), still present: a timed-out batch keeps its writer connection busy for up to two minutes. live_authorization_state_batch still checks out an ordinary pooled writer connection (live_authorization.rs:71) and runs the batch with no statement-level timeout (live_authorization.rs:120). revalidate_live_authorization_sessions still bounds it only with a 1-second tokio::time::timeout (state.rs:1082). That drops the future, but the statement keeps running on the server and the connection goes back through sqlx's return_to_pool ping, which waits for it. The writer session statement_timeout defaults to 2 minutes (buzz-db/src/runtime/mod.rs:750). So on a slow writer, every timed-out batch holds a writer connection for up to 120 s, and each 5-second scan adds up to four more. The "4 queries at a time" bound limits futures, not database work, and the scan can starve the serving writer pool under exactly the conditions the timeout exists for. Fix: bound the statement on the server, for example by running the batch in a transaction with SET LOCAL statement_timeout just under the 1-second wrapper. Also make a connection the scan times out or cancels close (with a bounded close) instead of returning to the pool.
IMPORTANT (design), partly addressed: closing sessions on a failed lookup still makes writer saturation worse. ARCHITECTURE.md:331-351 now states the security/availability tradeoff, which was one part of the earlier ask. Neither mitigation is in place, though. The failure branch (state.rs:1245) still closes every session on a target after the same 10-second grace, for pool and query timeouts as well as hard outages, and the closes still aren't jittered; ARCHITECTURE.md:349 says so. The desktop's jittered reconnect spreads that one client's re-admissions, but every root and audio socket on a pod still closes in the same sweep. Every other client reconnects on its own schedule, and each re-admission adds a writer read (the captured-owner read at auth.rs:420) against the writer that is already saturated. Please either keep sessions through lookup failures and apply the verdict on the next successful snapshot, or limit fail-closed to a much longer outage window and jitter the closes across it. Update the staleness budget and its compile-time assertion to match whichever you pick.
MINOR (non-blocking), all carried over from the earlier review and still present unless noted:
relayAuthPolicy.ts:34still copies four relay error strings intoSERVER_UNAVAILABLE_AUTH_REPLIESwith no shared constant or contract test. If a relay message is reworded, the desktop silently goes back to capped retries.- The
(None, true)invariant violation infinal_admission_check(auth.rs:441) still returns "internal error checking relay membership". The desktop retries that message forever, but here it signals a server bug, not an outage. admission_db_error_class(auth.rs:353) is still identical tolive_authorization_db_error_class(state.rs:1321); keep one.- The
useKnownAgentPubkeys.test.mjsobserver-race fix still doesn't touch this feature and would be cleaner in its own PR. ARCHITECTURE.md:283still records workstation timings (70 ms, 129 ms) that will go stale. The batch, concurrency, and deadline bounds are the durable facts.- New:
ARCHITECTURE.md:316calls the admission captured-owner read "bounded", butis_relay_member_writeratauth.rs:420has no deadline beyond pool acquisition and the 2-minute sessionstatement_timeout. That's the first finding again, on the admission path. Give it the same server-side bound, or drop "bounded" from the doc.
Checked and fine: live_authorization_allowed (state.rs:1270) still scopes every predicate to the bound community and refuses a captured-owner mismatch before it considers membership. Ordering targets by their oldest last-successful check (state.rs:1054) gives a target that missed a deadline priority on the next scan without letting a newer sibling socket hide it. Token-owner admission is recorded on both the root (auth.rs:1075) and audio (audio/handler.rs:1168) enrollment paths, so NIP-FI owner-derived sessions keep the path they were admitted under when NIP-OA is disabled. The source review decided this verdict, so no E2E run was done at this head.
7c29360 to
cc3041d
Compare
767fb7d to
c02857b
Compare
c02857b to
87275fa
Compare
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> (cherry picked from commit 92f13229ed3038d63f2a2d9c889bfd4c212e38e2) Signed-off-by: Brad Seiler <seiler@squareup.com>
Restore CommunityDisconnectReason near the top of state.rs and document scan deadline and reconnect-load limitations. Authorization behavior is unchanged. Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> (cherry picked from commit a4d1ab288d761d44eba25cc62d25b7d2858788dc) Signed-off-by: Brad Seiler <seiler@squareup.com>
Authenticate through root and audio handlers before a durable-only ban. Removing either production enrollment call must fail its regression. Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Exercise signed root and audio admission against an isolated running relay, durable direct-member and owner bans, a responsive bystander, and bounded database-failure closure. Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
…cture Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Keep a clear audio participant present through policy eviction so the huddle remains active for the database-failure phase. Reject bare closes with a labeled diagnostic. Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Keep NIP-FI owner delegation independent of the NIP-OA flag. Adapt membership fixtures and session identities to current main, and cover final admission through owner removal for both socket routes. Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Restore audio stored-owner failure accounting and keep live policy revalidation separate from deny-set revocation. Preserve denied-only priority over weak removal claims; cover denied and unavailable root closes. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Apply a transaction-local server deadline below the caller timeout, preserve tighter operator limits, and close cancelled or failed checkouts. Verify blocked-query cancellation, pool recovery, and writer timeout restoration on the same backend. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Spread failed-lookup closes across scans with a stable per-socket offset, keeping the conservative revocation bound at 30 seconds. Share the database query deadline and verify sibling sockets close across three sweeps. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Preserve the explicit access-log reason after the disconnect-reason match introduced on main. Extend the selected revalidation outage journey through the production close-reason reader. Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
87275fa to
d91fe8c
Compare
Summary
Close admitted root and huddle-audio sockets when Redis misses a ban or membership change. Recheck durable state with bounded batches, concurrency, deadlines and a fail-closed database-error grace period. Timeouts retain the existing session policy.
Preserve verified owner delegation through scans, including NIP-FI token-owner admission when NIP-OA is disabled. Both real root and audio enrollment paths record that admission source. The new database regression admits both paths, scans them and then removes the owner without a Redis notification; it does not exercise JWT verification or transport.
Recovered corrections retry transient root AUTH policy-read failures, prevent stale-target scan starvation and report the captured owner safely. Common mesh and fixture fixes already merged on main are no longer duplicated in this layer.
Related issue
BUZZ-271. Stack: #8529 → #8241 → #8239 → #8235. Invitation revocation #8237 and workflow-owner bans #8242 are merged.
Testing
Current head
d91fe8c485821dafe6b0296ba62691254352fb63in native stack 8725, rebased by Brad through GitHub onto main44b3c4823. Brad owns further ref updates.The explicit
AuthorizationUnavailableaccess-log label isauthorization_unavailable; its close frame is unchanged. The selected outage-grace regression reads the result through productionserver_close_reason.Independent review clears this correction and the restack. All 20 carried patches are unchanged in range-diff. The prior candidate passed relay clippy and 14 live-authorization/close-reason tests; a label mutation failed. That evidence carries over by unchanged patch review, and current-head hosted runtime CI is now terminal and green.
Historical private-relay tests are not current-head runtime acceptance. Huddle recovery during a continuing writer outage and human cross-pod verification remain separate follow-ups.
Normal pre-push hooks passed on integrated head
7ee3cf92f59a96349099d48fc115e5b8bb6a9716, including the branch-skew guard, unit tests, desktop checks/tests, Tauri checks, mobile checks/tests and file-size checks. These hooks validate the checked-out top tree; hosted CI remains the gate for each individual PR head.Current-head hosted CI is terminal and green, including runtime unit and PostgreSQL jobs. McKeon independently cleared all four current heads: carried patches range-diff unchanged (8/20/21/6), with no overlap between the newer main delta and stack files. The separate main-only security-review authorization check remains failed for this stacked base; no bypass or retarget was performed.
Historical local validation at integrated head
7ee3cf92f5: buzz-db/buzz-relay clippy passed with all targets and warnings denied; package tests passed 2,035/2,035; desktop build, Tauri check/tests, web build and mobile tests passed. The full PostgreSQL lane finished 1,503/1,505, with two failures still unresolved in that run:cluster_global_sample_writer_fails_closed_when_activity_is_maskedandhanging_redis_peer_times_out_before_the_health_listener_binds. The runner constructed apostgresql://URI; the fence test requires literalpostgres://, explaining its prefix panic. The Redis test exceeded its scheduling bound at 8.03 seconds; that local failure remains unexplained. No full localjust cipass is claimed. These receipts remain attributed to the historical head.The separate security-review workflow only authorizes PRs targeting main. Do not retarget or retry this stacked PR to bypass that restriction.
Human review and verification remain required. Existing review decisions are preserved. No merge, deployment, production repair or human acceptance was performed.
Generated with Codex