fix(desktop): unsquish WSLg window controls, harden GPU fallback

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.
This commit is contained in:
Austin Pickett 2026-07-24 10:58:47 -04:00
parent 68df6d3d50
commit cdc8d2e3d7
5 changed files with 114 additions and 11 deletions

View file

@ -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()

View file

@ -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', () => {

View file

@ -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
}
/**

View file

@ -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)')
})
})

View file

@ -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<HTMLButtonElement>) => event.preventDefault()
@ -27,7 +34,10 @@ export function WslgWindowControls({ isFullscreen, isMaximized }: WslgWindowCont
return (
<div
aria-label="Window controls"
className="fixed right-0 top-0 z-80 flex h-(--titlebar-height) items-stretch overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) text-[10px]"
className="fixed right-0 top-0 z-80 flex items-stretch overflow-hidden bg-(--ui-chat-surface-background) text-[10px]"
// Pin the real titlebar height: the shared --titlebar-height var is
// contextually zeroed inside the contrib shell, so read the constant.
style={{ height: `${TITLEBAR_HEIGHT}px` } as CSSProperties}
>
<button
aria-label="Minimize window"