mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
68df6d3d50
commit
cdc8d2e3d7
5 changed files with 114 additions and 11 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue