From cdc8d2e3d7f64fe1e9f4a93e898eadc2b1bdd1f8 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Fri, 24 Jul 2026 10:58:47 -0400 Subject: [PATCH] fix(desktop): unsquish WSLg window controls, harden GPU fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer-drawn WSLg controls sized their buttons with h-(--titlebar-height), but the contrib shell zeroes that var for content subtrees, so the cluster mounted inside it collapsed to a sliver — buttons squished into the middle of the bar instead of filling it. Pin the cluster to TITLEBAR_HEIGHT px and give each caption button a native 46px width and full height. GPU fallback now survives a force-quit mid crash-loop: the probing marker carries a persisted crash count, incremented on every GPU crash. If a prior session's carried count already hit the threshold, the next launch disables the passthrough immediately instead of re-entering the loop; otherwise it probes again seeding the runtime counter, so two half-loops still add up. --- apps/desktop/electron/main.ts | 13 ++++- .../electron/wslg-gpu-fallback.test.ts | 30 +++++++++++ apps/desktop/electron/wslg-gpu-fallback.ts | 54 ++++++++++++++++--- .../app/shell/wslg-window-controls.test.tsx | 12 +++++ .../src/app/shell/wslg-window-controls.tsx | 16 ++++-- 5 files changed, 114 insertions(+), 11 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 50f69b38339..f450f66c373 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -211,6 +211,7 @@ import { gpuCrashEngagesFallback, isGpuChildCrash, readWslgGpuMarker, + recordGpuCrash, writeWslgGpuMarker, wslgGpuMarkerAfterSuccessfulBoot } from './wslg-gpu-fallback' @@ -271,12 +272,17 @@ let wslgGpuCrashCount = 0 if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { const wslgUserData = app.getPath('userData') + const priorGpuMarker = readWslgGpuMarker(wslgUserData) const gpuDecision = decideWslgGpuLaunch({ - marker: readWslgGpuMarker(wslgUserData), + marker: priorGpuMarker, appVersion: app.getVersion() }) + // Seed the runtime counter with crashes carried from a prior probing session + // so two half crash-loops (each ending in a force-quit) still add up. + wslgGpuCrashCount = priorGpuMarker?.state === 'probing' ? (priorGpuMarker.crashes ?? 0) : 0 + writeWslgGpuMarker(wslgUserData, gpuDecision.nextMarker) if (gpuDecision.enableGpu) { @@ -410,6 +416,11 @@ if (IS_WSL && !wslgGpuFallbackActive) { wslgGpuCrashCount += 1 + // Persist progress every crash so a force-quit mid crash-loop still carries + // toward the fallback on the next launch (recordGpuCrash writes a probing + // marker with the running count). + writeWslgGpuMarker(app.getPath('userData'), recordGpuCrash(wslgGpuCrashCount - 1, app.getVersion())) + const fallback = gpuCrashEngagesFallback({ crashCount: wslgGpuCrashCount, appVersion: app.getVersion() diff --git a/apps/desktop/electron/wslg-gpu-fallback.test.ts b/apps/desktop/electron/wslg-gpu-fallback.test.ts index 31ebc9d31c7..09a6bb5dcf6 100644 --- a/apps/desktop/electron/wslg-gpu-fallback.test.ts +++ b/apps/desktop/electron/wslg-gpu-fallback.test.ts @@ -9,6 +9,7 @@ import { isGpuChildCrash, parseWslgGpuMarker, readWslgGpuMarker, + recordGpuCrash, writeWslgGpuMarker, wslgGpuFallbackMarker, wslgGpuMarkerAfterSuccessfulBoot, @@ -112,6 +113,35 @@ describe('decideWslgGpuLaunch', () => { assert.equal(d.enableGpu, false) assert.equal(d.nextMarker.version, '1.0.0') }) + + test('probing marker under threshold → probe again, carrying crash count', () => { + const d = decideWslgGpuLaunch({ marker: { state: 'probing', crashes: 1 }, appVersion: '1.0.0' }) + + assert.equal(d.enableGpu, true) + assert.deepEqual(d.nextMarker, { state: 'probing', crashes: 1 }) + }) + + test('probing marker at/over threshold (carried across force-quit) → disable now', () => { + const d = decideWslgGpuLaunch({ + marker: { state: 'probing', crashes: GPU_CRASHES_BEFORE_FALLBACK }, + appVersion: '1.0.0' + }) + + assert.equal(d.enableGpu, false) + assert.equal(d.reason, 'carried-crash-loop') + assert.deepEqual(d.nextMarker, { state: 'fallback', version: '1.0.0' }) + }) +}) + +describe('recordGpuCrash', () => { + test('increments the crash count on a probing marker', () => { + assert.deepEqual(recordGpuCrash(0), { state: 'probing', crashes: 1 }) + assert.deepEqual(recordGpuCrash(2, '1.0.0'), { state: 'probing', crashes: 3, version: '1.0.0' }) + }) + + test('treats a non-finite previous count as zero', () => { + assert.deepEqual(recordGpuCrash(NaN), { state: 'probing', crashes: 1 }) + }) }) describe('gpuCrashEngagesFallback', () => { diff --git a/apps/desktop/electron/wslg-gpu-fallback.ts b/apps/desktop/electron/wslg-gpu-fallback.ts index f29be0daa1b..ebd2ba1766c 100644 --- a/apps/desktop/electron/wslg-gpu-fallback.ts +++ b/apps/desktop/electron/wslg-gpu-fallback.ts @@ -37,6 +37,9 @@ export interface WslgGpuMarker { version?: string /** This launch is a post-update GPU re-probe after a prior fallback. */ reprobe?: boolean + /** GPU crashes observed so far while `probing`. Persisted each crash so a + * force-quit mid crash-loop still carries progress into the next launch. */ + crashes?: number } export function wslgGpuMarkerPath(userDataDir: string): string { @@ -65,6 +68,12 @@ export function parseWslgGpuMarker(raw: unknown): WslgGpuMarker | null { marker.reprobe = true } + const crashes = Number(record.crashes) + + if (Number.isInteger(crashes) && crashes > 0) { + marker.crashes = crashes + } + return marker } @@ -129,17 +138,20 @@ export interface WslgGpuLaunchDecision { * the vGPU AND what the marker becomes for crash detection on the next launch. * * - No marker / clean `ok` → probe the GPU (`probing`). - * - `probing` left behind → last launch crashed before it could mark `ok`, but - * a single `probing` is tolerated (could be a manual kill / power loss); - * the runtime crash counter is what actually trips the sticky fallback. + * - `probing` left behind → the last launch crashed before it could mark `ok`. + * Its persisted `crashes` count carries forward: if it already reached the + * threshold, engage the sticky fallback now (a force-quit mid crash-loop still + * self-heals on the next launch); otherwise probe again, seeding the runtime + * counter with the carried crashes so two half-loops still add up. * - `fallback` is sticky within one app version. A version change re-probes the * GPU once (`reprobe`) so a fixed host returns to acceleration; if that * re-probe launch also crashes, the runtime counter re-arms the fallback. */ export function decideWslgGpuLaunch( - options: { marker?: WslgGpuMarker | null; appVersion?: string } = {} + options: { marker?: WslgGpuMarker | null; appVersion?: string; threshold?: number } = {} ): WslgGpuLaunchDecision { const appVersion = String(options.appVersion || '') + const threshold = options.threshold ?? GPU_CRASHES_BEFORE_FALLBACK const marker = options.marker ?? null if (marker?.state === 'fallback') { @@ -155,9 +167,37 @@ export function decideWslgGpuLaunch( } } - // No marker, a clean `ok`, or a prior `probing` — try the GPU. Runtime crash - // detection (see gpuCrashEngagesFallback) trips the sticky fallback. - return { enableGpu: true, reason: null, nextMarker: { state: 'probing' } } + // A prior probing session that carried enough crashes across a force-quit → + // engage the fallback now instead of re-entering the crash loop. + const carriedCrashes = marker?.state === 'probing' ? (marker.crashes ?? 0) : 0 + + if (carriedCrashes >= threshold) { + return { enableGpu: false, reason: 'carried-crash-loop', nextMarker: wslgGpuFallbackMarker(appVersion) } + } + + // No marker, a clean `ok`, or a probing marker under the threshold — try the + // GPU, seeding the counter with any carried crashes. Runtime crash detection + // (see gpuCrashEngagesFallback) trips the sticky fallback. + return { + enableGpu: true, + reason: null, + nextMarker: carriedCrashes > 0 ? { state: 'probing', crashes: carriedCrashes } : { state: 'probing' } + } +} + +/** + * Persist an incremented crash count on the `probing` marker so progress toward + * the fallback survives a force-quit mid crash-loop. Returns the marker to write. + */ +export function recordGpuCrash(previousCrashes: number, appVersion?: string): WslgGpuMarker { + const crashes = (Number.isFinite(previousCrashes) ? previousCrashes : 0) + 1 + const marker: WslgGpuMarker = { state: 'probing', crashes } + + if (appVersion) { + marker.version = appVersion + } + + return marker } /** diff --git a/apps/desktop/src/app/shell/wslg-window-controls.test.tsx b/apps/desktop/src/app/shell/wslg-window-controls.test.tsx index cec553d258f..5897187a5bc 100644 --- a/apps/desktop/src/app/shell/wslg-window-controls.test.tsx +++ b/apps/desktop/src/app/shell/wslg-window-controls.test.tsx @@ -84,4 +84,16 @@ describe('WslgWindowControls', () => { expect(event.defaultPrevented).toBe(true) expect(windowControls.toggleMaximize).toHaveBeenCalledOnce() }) + + it('pins an explicit pixel height instead of the contextually-zeroed titlebar var', () => { + desktopWindow.hermesDesktop = { windowControls } as unknown as Window['hermesDesktop'] + + renderControls() + + // The contrib shell zeroes --titlebar-height for content subtrees; the + // cluster must set its own height in px so the buttons don't collapse. + const cluster = screen.getByLabelText('Window controls') + expect(cluster.style.height).toMatch(/^\d+px$/) + expect(cluster.className).not.toContain('h-(--titlebar-height)') + }) }) diff --git a/apps/desktop/src/app/shell/wslg-window-controls.tsx b/apps/desktop/src/app/shell/wslg-window-controls.tsx index 73c4b121d7e..a623533bd1e 100644 --- a/apps/desktop/src/app/shell/wslg-window-controls.tsx +++ b/apps/desktop/src/app/shell/wslg-window-controls.tsx @@ -1,4 +1,4 @@ -import type { PointerEvent } from 'react' +import type { CSSProperties, PointerEvent } from 'react' import { useLocation } from 'react-router-dom' import { Codicon } from '@/components/ui/codicon' @@ -6,13 +6,20 @@ import { cn } from '@/lib/utils' import { appViewForPath, isOverlayView } from '../routes' +import { TITLEBAR_HEIGHT } from './titlebar' + interface WslgWindowControlsProps { isFullscreen: boolean isMaximized: boolean } +// Full-height caption buttons sized to match the native Windows cluster +// (~46px wide × full titlebar height). `h-full` fills the cluster box, whose +// height is pinned to TITLEBAR_HEIGHT below — NOT var(--titlebar-height), which +// the contrib shell zeroes for content subtrees (controller.tsx), collapsing +// the buttons if inherited. const buttonClass = - 'grid h-(--titlebar-height) w-11 place-items-center border-0 bg-transparent p-0 text-muted-foreground transition-colors duration-75 select-none [-webkit-app-region:no-drag] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring hover:bg-white/10 hover:text-foreground active:bg-white/15' + 'grid h-full w-[46px] place-items-center border-0 bg-transparent p-0 text-muted-foreground transition-colors duration-75 select-none [-webkit-app-region:no-drag] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring hover:bg-white/10 hover:text-foreground active:bg-white/15' const preserveRendererFocus = (event: PointerEvent) => event.preventDefault() @@ -27,7 +34,10 @@ export function WslgWindowControls({ isFullscreen, isMaximized }: WslgWindowCont return (