Skip to content

fix: restore accelerated scrolling on Linux WebKitGTK - #210

Merged
Zeus-Deus merged 2 commits into
mainfrom
fix-chat-scrolling-behavior
Jul 27, 2026
Merged

fix: restore accelerated scrolling on Linux WebKitGTK#210
Zeus-Deus merged 2 commits into
mainfrom
fix-chat-scrolling-behavior

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Problem

Scrolling across the entire app on Linux (chat transcript, sidebar, menus, dialogs) felt sluggish, inconsistent, and got slower the faster you scrolled — despite multiple past attempts (#119, #141, #156, #185) that each fixed real symptoms but never the cause.

Root causes

1. The app forced WebKitGTK into CPU rendering. The April Wayland-crash workaround set WEBKIT_DISABLE_DMABUF_RENDERER=1 + WEBKIT_DISABLE_COMPOSITING_MODE=1 process-wide, disabling accelerated compositing and threaded scrolling for every surface. Measured: ~56ms per scroll frame (~18fps) vs ~16ms (~63fps) accelerated. This is why the jank was app-wide and why frontend-level fixes never stuck — PR #185 explicitly deferred this flag as a follow-up.

2. WebKitGTK smooth scrolling is broken for high-resolution wheels (WebKit bug 258926): each wheel event retargets the scroll animation and resets its clock against a 200ms eased duration, so faster input produces slower motion by construction. enable-smooth-scrolling defaults to TRUE and wry never disables it.

Fix

  • Replace both disable flags with WEBKIT_DMABUF_RENDERER_FORCE_SHM=1: keeps GPU compositing + async scrolling, routes only the final buffer handoff through shared memory — avoiding the dual-GPU Wayland explicit-sync crash (WebKit bug 280210, "Error 71") and the GBM EGL failure. Verified crash-free and stable on the affected hardware (RTX 3090 + AMD iGPU, Wayland and X11).
  • Crash sentinel (webview_tuning.rs): if accelerated startup fails twice consecutively, fall back (sticky) to the legacy compatibility flags. Cleared on a successful main-window page load; duplicate launches can't fake a crash streak. CODEMUX_WEBKIT_COMPAT=1 and WEBKIT_DMABUF_RENDERER_FORCE_SHM=0 remain explicit escape hatches.
  • Smooth scrolling disabled on every webview window (applied at setup and on each page load), with an opt-in toggle under Settings → Appearance (Linux only, persisted, applied at boot).
  • Env hygiene so stale flags can't silently defeat the fix: tauri:dev scripts no longer hardcode a disable flag; flags inherited from an older running app instance are detected and scrubbed (with a stderr diagnostic); PTY/terminal/daemon children no longer inherit renderer vars at all.

Frontend polish

  • Transcript edge fade re-enabled everywhere (measured free once composited); auto-disables via the new get_renderer_mode command whenever the app actually runs in compatibility mode
  • cm-sweep animates transform instead of left (verified pixel-identical travel; no per-frame layout)
  • Preset bar / title-bar tabs: shared non-passive wheel handler (useHorizontalWheelScroll) with deltaMode normalization; preventDefault only when the strip consumed the scroll, so edge wheels still chain
  • MessageTrail normalizes wheel deltaMode before scrollTop writes
  • Scroll-lock wheel guard added to the launch reasoning picker (audited all dialogs against the actual react-remove-scroll lock rule; other surfaces correctly need none)
  • Removed six undefined no-op scrollbar/fade utility classes; narrowed a sidebar transition-all

Deliberately deferred

  • MessageScroller's per-scroll-event O(N) geometry walk: re-measure on the composited build first (~2ms/frame at 600 rows once accelerated); revisit virtualization if long threads still bite
  • Scroll-anchoring shim stays until WebKitGTK ships CSS scroll anchoring (~2.54)

Verification

  • cargo check clean; cargo test: 2263 lib tests + all integration suites passing (incl. 8 new webview_tuning tests)
  • tsc --noEmit clean; vitest: 212 files / 3070 tests passing (new tests for wheel normalization, smooth-scrolling setting/hook, fade gating, tab-strip wheel behavior)
  • Manually validated in a dev build: scrolling smooth, rate-dependent slow-motion gone; a freeze observed under 3 concurrent streaming chats was root-caused to inherited legacy env flags re-enabling CPU rendering and is eliminated by the env-hygiene changes + the renderer-aware fade gate

Scrolling felt sluggish and inconsistent across the whole app (chat,
sidebar, menus) on Linux. Two root causes:

1. The Wayland crash workaround set WEBKIT_DISABLE_DMABUF_RENDERER=1 and
   WEBKIT_DISABLE_COMPOSITING_MODE=1 process-wide, forcing WebKitGTK into
   non-accelerated CPU rendering (~56ms per scroll frame vs ~16ms
   accelerated). Replaced with WEBKIT_DMABUF_RENDERER_FORCE_SHM=1, which
   keeps GPU compositing and threaded scrolling while routing the final
   buffer handoff through shared memory - avoiding both the dual-GPU
   Wayland explicit-sync crash (Error 71, WebKit bug 280210) and the GBM
   EGL failure. A startup crash sentinel falls back to the legacy
   compatibility flags after repeated failed accelerated launches.

2. WebKitGTK's enable-smooth-scrolling default animates wheel input with
   a per-event retargeted 200ms easing (WebKit bug 258926), so high-rate
   wheel input made content move slower the faster you scrolled. Smooth
   scrolling is now disabled on every webview window by default, with an
   opt-in toggle under Settings > Appearance.

Renderer env hygiene, so stale flags can never defeat the fix:
- tauri:dev scripts no longer hardcode WEBKIT_DISABLE_DMABUF_RENDERER
- flags inherited from an older parent app instance are scrubbed
  (CODEMUX_WEBKIT_COMPAT=1 and =0 values remain explicit escape hatches)
- PTY/terminal/daemon children no longer inherit renderer vars

Frontend scroll polish:
- transcript edge fade re-enabled (free once composited; auto-disables
  via get_renderer_mode when the app runs in compatibility mode)
- cm-sweep animates transform instead of left (no per-frame layout)
- preset bar / title bar tabs: shared non-passive wheel handler with
  deltaMode normalization and preventDefault only when consumed
- MessageTrail normalizes wheel deltaMode before scrollTop writes
- scroll-lock wheel guard added to the launch reasoning picker
- removed six undefined (no-op) scrollbar/fade utility classes
- sidebar row transition-all narrowed to the properties it animates

Verified: cargo test 2263 lib tests + integration suites, vitest 3070
tests across 212 files, tsc clean.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Deep review — Opus 5 (high effort)

I gave this one the extra pass. Root-causing four previous attempts down to a single process-wide env var is genuinely good work, and the fix is the right shape: WEBKIT_DMABUF_RENDERER_FORCE_SHM keeps compositing and async scrolling while removing the buffer transport that was actually crashing, rather than removing compositing to avoid the crash.

What I verified as correct

Rather than list what I read, here is what I actively went looking to break and couldn't:

  • The cm-sweep transform math is exact, not approximately exact. Percentages in translateX resolve against the element's own width, so for the 38% segment: -100% × 38% = -38% of the track (old left: -38%) and 263% × 38% = 99.94% (old left: 100%). For the 40% segment with the overrides: -95% × 40% = -38% and 250% × 40% = 100%. Identical travel. And cm-sweep has exactly two call sites in the tree (SubagentActivityBar, working-indicator) — both got left-0, and the 40% one got its overrides. Nothing was missed. Reduced-motion still parks the segment at the track's left edge either way.
  • The six removed utility classes really are dead. No @utility blocks in globals.css and no tailwind-scrollbar in package.json, so scroll-fade-x, scroll-fade-b, scrollbar-thin, scrollbar-gutter-stable and both data-autoscrolling:scrollbar-* were no-ops. The arbitrary-property [scrollbar-gutter:stable] was correctly kept. Zero visual change.
  • useHorizontalWheelScroll chains correctly at the edges — it clamps to [0, max] and only preventDefaults when scrollLeft actually moved, so a wheel at either end still reaches the ancestor. The hook sits above every early return in preset-bar.tsx (line ~199, the bar_visible return is at ~206), so no conditional-hook problem.
  • webkit2gtk = "=2.0.2" is right. Cargo.lock confirms wry pulls exactly 2.0.2, and the dep is inside the [target.'cfg(target_os = "linux")'.dependencies] block, so Cargo unifies them into one crate and PlatformWebview::inner() type-checks. The comment's reasoning is accurate.
  • The shared on_page_load hook is safe in headless serve. build_core_app is used by both the Wry GUI and the MockRuntime daemon, so I checked: MockRuntime's with_webview is fn with_webview<F>(&self, f: F) -> Result<()> { Ok(()) } — a no-op, not unimplemented!(). No panic risk.
  • The crash sentinel is not armed by CLI subcommands or codemux serve. This was my main worry — a sentinel incremented on every startup and cleared only on a main-window page load looked like it would let two codemux browser open invocations bury the GUI on CPU rendering. It doesn't: main() dispatches maybe_run_cli() and returns before run() for both CliOutcome::Handled and RunServe, and run_serve never calls configure_renderer_env. The OpenFlow-agent guard and the control_server_is_running() early return are also both ahead of run(). The duplicate-launch clear is correct as well: the single-instance callback runs in the first process, which owns SENTINEL_OWNED, and rewrites the shared file to zero.
  • useSyncExternalStore gets a stable boolean snapshot and a module-level subscribe, and the late setRendererMode correctly invalidates cachedFade before notifying. The launch-reasoning-picker stopPropagation matches the pattern four sibling pickers already use.

Findings

1. The compat-mode probe is value-blind, and the rest of the module isn't

let non_composited = std::env::var_os(DISABLE_DMABUF_ENV).is_some()
    || std::env::var_os(DISABLE_COMPOSITING_ENV).is_some();
COMPAT_MODE.store(non_composited, Ordering::Release);

WEBKIT_DISABLE_DMABUF_RENDERER=0 means "do not disable the DMA-BUF renderer" — WebKit treats only 0 as off, which this module already knows: scrub_decision goes out of its way to never scrub a 0, with a test (keeps_deliberate_opt_out_values) pinning it. But this branch reads presence, not value, so a user who sets WEBKIT_DISABLE_COMPOSITING_MODE=0 gets get_renderer_mode() == "compatibility" and loses the transcript edge-fade on a webview that is fully composited.

Cosmetic in effect, but it's the one place in a carefully value-aware file that ignores the value. var(...).as_deref() != Ok("0") on each side would make it consistent.

2. FORCE_SHM is a DMA-BUF-era variable, and the flag it replaces was the one fixing the crash

WEBKIT_DMABUF_RENDERER_FORCE_SHM only exists alongside WebKitGTK's DMA-BUF backing store. I confirmed it's present locally (strings /usr/lib/libwebkit2gtk-4.1.so.0 on 2.52.5 lists WEBKIT_DMABUF_RENDERER_FORCE_SHM, WEBKIT_DMABUF_RENDERER_DISABLE_GBM, WEBKIT_DMABUF_RENDERER_BUFFER_FORMAT), but on a runtime predating that backing store the variable is simply unrecognised — and this PR simultaneously stops setting WEBKIT_DISABLE_COMPOSITING_MODE, which is the flag that actually fixed the dual-GPU "Error 71" on those older stacks.

So on an older WebKitGTK the net effect is: accelerated compositing back on, no SHM transport, original crash returns — twice, until the sentinel trips. The sentinel is a genuine safety net, but "two crashes then it self-heals" is a rough first upgrade experience for anyone on an LTS distro.

You've just added the webkit2gtk dep, which gives you webkit_get_minor_version() — a plain library call, no GTK init required, so it's safe to call from configure_renderer_env. Gating the accelerated default on a known-good floor would make the first launch safe too, and would document the floor in code rather than leaving it implicit. At minimum, CONTRIBUTING.md's "Known Gotchas" should state the minimum WebKitGTK the accelerated path assumes, next to the existing 4.1 note.

3. The sentinel counts every failed startup, not every renderer failure

The counter is bumped before GTK init and cleared only on a finished main-window page load. Anything that kills the process in between counts: Ctrl-C during startup, a panic in setup, a failed DB migration, an OOM, a SIGTERM from a session manager on logout. Two in a row and the machine is pinned to CPU rendering — stickily, by design — with a single stderr line as notification and "delete webview-renderer.sentinel" as the only recovery. Most GUI users never see stderr.

Given you're already adding a Linux-only Settings → Appearance → Scrolling section that calls get_renderer_mode's sibling command, that panel is the natural place to surface it: "Renderer: compatibility (CPU) — accelerated startup failed twice. [Retry accelerated]". That turns an invisible sticky degradation into something a user can see and undo. An expiry (retry the accelerated path if the fallback is older than N days) would be a cheaper version of the same idea.

Worth noting the counter is also bumped-then-cleared on the normal happy path, so this file is written on every single launch. Not a problem, just means it isn't a rare-path artefact.

4. A hand-typed override inside a Codemux terminal is scrubbed

WEBKIT_DISABLE_DMABUF_RENDERER=1 codemux typed deliberately in a Codemux terminal is indistinguishable from inheritance and gets scrubbed. This is documented, the stderr diagnostic names CODEMUX_WEBKIT_COMPAT=1, and CONTRIBUTING.md points there too — so it's a defensible judgement call rather than a defect. It is, though, the most surprising behaviour in the file, and the person most likely to hit it is a developer debugging a renderer crash from inside the app. Since the PTY layer now strips these vars from children anyway, the inheritance case it defends against is already largely closed; you could plausibly narrow the scrub to FORCE_SHM only and let the legacy flags through as user intent.

5. Two small things

  • scrollbar-none survives in attachment.tsx while its six dead siblings were removed in the same diff — it's equally undefined. And the two data-autoscrolling:scrollbar-* classes encoded a real design intent (hide the thumb while autoscrolling) that is now gone from the code with nothing recording that it was ever wanted. A one-line comment or a follow-up issue would keep the intent alive.
  • decideTranscriptFade's first parameter is now dead. It's renamed _userAgent but still defaults to navigator.userAgent and is still passed at both call sites, so production evaluates it on every uncached call. The "kept so the decision stays engine-injectable" rationale is speculative; the RendererMode argument is the real axis now. Dropping it would also let the test fixtures go.

Not findings, but worth saying

The two deferred items are correctly deferred. MessageScroller's per-scroll-event O(N) geometry walk is exactly the kind of thing that should be re-measured on the composited build before anyone reaches for virtualization — and the scroll-anchoring shim genuinely can't come out until WebKitGTK ships CSS scroll anchoring.

The LINE_HEIGHT_PX = 16 constant is described as matching "the browsers' own default step for DOM_DELTA_LINE", which is generous — engines disagree (Firefox effectively uses ~3× a computed line height per notch). It's a heuristic, and a reasonable one; the comment just claims more precision than exists.

Conflicts: src-tauri/src/lib.rs, src-tauri/src/terminal/mod.rs and src/App.tsx with #209; sidebar-workspace-row.tsx with #206 and #213; settings-view.tsx with #206.

@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Extra-deep review by GPT 5.6 Sol xhigh found two functional issues:

  1. The native renderer controls are applied to the wrong machine in web-remote clients. App unconditionally runs useRendererModeInit and useSmoothScrollingInit; SmoothScrollingSection decides visibility from the frontend's navigator.userAgent. In the web-remote runtime, however, invoke("get_renderer_mode") and invoke("set_smooth_scrolling") execute on the desktop host. Consequences:

    • a compatibility-rendered host disables the transcript fade in an accelerated remote browser, even though the host renderer is not drawing that UI;
    • a Linux WebKitGTK remote browser sees the Smooth scrolling toggle, but toggling it changes the host desktop webview and has no effect on the browser the user is interacting with.

    The repo already has isRemoteClient() specifically for this split. Gate the native renderer probe/init and the settings section off for remote clients (or otherwise make the decision/control genuinely client-local).

  2. The sticky crash sentinel counts clean or unrelated early exits as renderer crashes. configure_renderer_env increments the durable counter before Tauri starts (webview_tuning.rs:269-290), and the only clears are a finished main-page load or the duplicate-instance callback. Closing the app before PageLoadEvent::Finished, killing a dev launch during startup, or failing in unrelated setup twice therefore reaches the same threshold as two renderer-init crashes. The next run permanently selects the CPU renderer because the fallback intentionally never clears itself; only manually deleting the sentinel re-arms acceleration. A clean RunEvent::Exit should disarm an owned sentinel, and the persisted record should have enough phase/age information not to turn unrelated startup failures into a permanent renderer downgrade.

I also checked the implementation against WebKitGTK's current WEBKIT_DMABUF_RENDERER_FORCE_SHM transport logic and bugs 258926/280210, traced every renderer-env inheritance path (in-process PTY, daemon supervisor, adopted daemon), every existing/future webview application path, the runtime toggle, transcript-fade cache/subscription, wheel normalization, and the compositor-only animation changes.

Validation: cargo check passed; all 8 new webview_tuning Rust tests passed inside the full suite; the full Rust run reached 2262 passed and then stopped on one unrelated live OpenCode readiness timeout; npm run check passed; and the five focused frontend suites passed 31/31 tests. The PR's GitHub checks are green.

- Make the compat-mode report value-aware: WEBKIT_DISABLE_*=0 is
  WebKit's own opt-out and no longer flags the process as CPU-rendered
- Gate the accelerated FORCE_SHM default on WebKitGTK 2.40+ (the
  DMA-BUF renderer floor); older runtimes go straight to the legacy
  compatibility flags instead of crashing until the sentinel trips
- Disarm the crash sentinel on a graceful run-loop exit so interrupted
  startups no longer count toward the CPU-rendering fallback threshold
- Skip the renderer-mode probe and smooth-scrolling init on the web
  remote client and hide the Settings scrolling toggle there: both act
  on the desktop host's webview, not the browser the user is viewing
- Drop the dead userAgent parameter from decideTranscriptFade and the
  unused scrollbar-none class in the attachment carousel
@Zeus-Deus
Zeus-Deus merged commit ad1c81f into main Jul 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant