326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
decideLinuxGpuLaunch,
|
|
disableGpuSwitchNeededForReason,
|
|
linuxGpuChildDeathPath,
|
|
linuxGpuFallbackMarker,
|
|
linuxGpuMarkerAfterSuccessfulBoot,
|
|
parseLinuxGpuMarker,
|
|
shouldEngageSilentGpuRetryFallback,
|
|
shouldRelaunchForLinuxGpuCrash
|
|
} from './linux-gpu-fallback'
|
|
|
|
const LINUX = {
|
|
platform: 'linux' as const,
|
|
argv: [] as string[],
|
|
env: {} as NodeJS.ProcessEnv,
|
|
marker: null,
|
|
appVersion: '0.21.5',
|
|
remoteDisplayReason: null as string | null,
|
|
nvidiaFallbackActive: false
|
|
}
|
|
|
|
describe('parseLinuxGpuMarker', () => {
|
|
it('accepts a well-formed fallback marker', () => {
|
|
expect(parseLinuxGpuMarker({ state: 'fallback', reason: 'gpu-launch-failure', version: '0.21.5' })).toEqual({
|
|
state: 'fallback',
|
|
reason: 'gpu-launch-failure',
|
|
version: '0.21.5'
|
|
})
|
|
})
|
|
|
|
it('rejects garbage', () => {
|
|
expect(parseLinuxGpuMarker(null)).toBeNull()
|
|
expect(parseLinuxGpuMarker({ state: 'nope' })).toBeNull()
|
|
expect(parseLinuxGpuMarker('fallback')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('decideLinuxGpuLaunch', () => {
|
|
it('stays off on a clean first boot and records booting', () => {
|
|
const decision = decideLinuxGpuLaunch(LINUX)
|
|
|
|
expect(decision.enable).toBe(false)
|
|
expect(decision.nextMarker.state).toBe('booting')
|
|
})
|
|
|
|
it('stays off on non-linux platforms', () => {
|
|
const decision = decideLinuxGpuLaunch({ ...LINUX, platform: 'darwin' })
|
|
|
|
expect(decision.enable).toBe(false)
|
|
})
|
|
|
|
it('re-engages software rendering from a sticky fallback marker', () => {
|
|
const decision = decideLinuxGpuLaunch({
|
|
...LINUX,
|
|
marker: { state: 'fallback', reason: 'gpu-launch-failure', version: '0.21.5' }
|
|
})
|
|
|
|
expect(decision.enable).toBe(true)
|
|
expect(decision.reason).toContain('sticky')
|
|
expect(decision.nextMarker.state).toBe('fallback')
|
|
})
|
|
|
|
it('re-probes the GPU once after an app update instead of degrading forever', () => {
|
|
const decision = decideLinuxGpuLaunch({
|
|
...LINUX,
|
|
marker: { state: 'fallback', reason: 'gpu-launch-failure', version: '0.21.4' }
|
|
})
|
|
|
|
expect(decision.enable).toBe(false)
|
|
expect(decision.nextMarker).toMatchObject({ state: 'booting', reprobe: true })
|
|
})
|
|
|
|
it('engages after two consecutive aborted boots, not one', () => {
|
|
const first = decideLinuxGpuLaunch({ ...LINUX, marker: { state: 'booting' } })
|
|
|
|
expect(first.enable).toBe(false)
|
|
expect(first.nextMarker).toMatchObject({ state: 'booting', bootAborts: 1 })
|
|
|
|
const second = decideLinuxGpuLaunch({
|
|
...LINUX,
|
|
marker: { state: 'booting', bootAborts: 1 }
|
|
})
|
|
|
|
expect(second.enable).toBe(true)
|
|
expect(second.reason).toContain('boot-loop')
|
|
})
|
|
|
|
it('HERMES_DESKTOP_DISABLE_GPU=0 keeps the GPU on and clears the stale fallback marker', () => {
|
|
const decision = decideLinuxGpuLaunch({
|
|
...LINUX,
|
|
env: { HERMES_DESKTOP_DISABLE_GPU: '0' },
|
|
marker: { state: 'fallback', reason: 'gpu-launch-failure', version: '0.21.5' }
|
|
})
|
|
|
|
expect(decision.enable).toBe(false)
|
|
// The override boot is the recovery path the file documents: it must not leave the sticky
|
|
// marker behind, or the next override-free launch re-engages software rendering from it
|
|
// and only an app-version change would ever clear it.
|
|
expect(decision.nextMarker).toEqual({ state: 'booting' })
|
|
})
|
|
|
|
it('keeps the relaunch path sticky when --disable-gpu is already on argv', () => {
|
|
// Same marker, different entry point: the one-shot relaunch writes `fallback` and re-execs
|
|
// with --disable-gpu, so that boot must preserve it — clearing it here would re-arm the
|
|
// GPU-child retry loop this fallback exists to stop.
|
|
const decision = decideLinuxGpuLaunch({
|
|
...LINUX,
|
|
argv: ['--disable-gpu'],
|
|
marker: { state: 'fallback', reason: 'gpu-launch-failure', version: '0.21.5' }
|
|
})
|
|
|
|
expect(decision.reason).toBe('already-enabled')
|
|
expect(decision.nextMarker).toMatchObject({
|
|
state: 'fallback',
|
|
reason: 'gpu-launch-failure',
|
|
version: '0.21.5'
|
|
})
|
|
})
|
|
|
|
it('stays off when software rendering is already forced', () => {
|
|
expect(decideLinuxGpuLaunch({ ...LINUX, remoteDisplayReason: 'ssh-session' }).enable).toBe(false)
|
|
expect(decideLinuxGpuLaunch({ ...LINUX, nvidiaFallbackActive: true }).enable).toBe(false)
|
|
expect(decideLinuxGpuLaunch({ ...LINUX, argv: ['--disable-gpu'] }).reason).toBe('already-enabled')
|
|
})
|
|
})
|
|
|
|
describe('shouldRelaunchForLinuxGpuCrash', () => {
|
|
it('relaunches once on a GPU launch failure (error_code=1002 class)', () => {
|
|
expect(
|
|
shouldRelaunchForLinuxGpuCrash({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'launch-failure' },
|
|
alreadySoftware: false,
|
|
relaunchAttempted: false
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
it('relaunches once on a GPU crash (SIGTRAP class)', () => {
|
|
expect(
|
|
shouldRelaunchForLinuxGpuCrash({
|
|
platform: 'linux',
|
|
details: { type: 'gpu', reason: 'crashed' },
|
|
alreadySoftware: false,
|
|
relaunchAttempted: false
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
it('never relaunches twice in one process', () => {
|
|
expect(
|
|
shouldRelaunchForLinuxGpuCrash({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'launch-failure' },
|
|
alreadySoftware: false,
|
|
relaunchAttempted: true
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('ignores non-GPU deaths and clean exits', () => {
|
|
const base = { platform: 'linux' as const, alreadySoftware: false, relaunchAttempted: false }
|
|
|
|
expect(shouldRelaunchForLinuxGpuCrash({ ...base, details: { type: 'renderer', reason: 'crashed' } })).toBe(false)
|
|
expect(shouldRelaunchForLinuxGpuCrash({ ...base, details: { type: 'GPU', reason: 'clean-exit' } })).toBe(false)
|
|
expect(shouldRelaunchForLinuxGpuCrash({ ...base, details: null })).toBe(false)
|
|
})
|
|
|
|
it('stays off when software rendering is already active or off linux', () => {
|
|
expect(
|
|
shouldRelaunchForLinuxGpuCrash({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'crashed' },
|
|
alreadySoftware: true,
|
|
relaunchAttempted: false
|
|
})
|
|
).toBe(false)
|
|
expect(
|
|
shouldRelaunchForLinuxGpuCrash({
|
|
platform: 'win32',
|
|
details: { type: 'GPU', reason: 'crashed' },
|
|
alreadySoftware: false,
|
|
relaunchAttempted: false
|
|
})
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('linuxGpuMarkerAfterSuccessfulBoot', () => {
|
|
it('marks a clean boot ok', () => {
|
|
expect(linuxGpuMarkerAfterSuccessfulBoot({ fallbackActive: false })).toEqual({ state: 'ok' })
|
|
})
|
|
|
|
it('keeps the sticky fallback after a software-rendered boot', () => {
|
|
expect(linuxGpuMarkerAfterSuccessfulBoot({ fallbackActive: true, appVersion: '0.21.5' })).toEqual({
|
|
state: 'fallback',
|
|
reason: 'gpu-launch-failure',
|
|
version: '0.21.5'
|
|
})
|
|
})
|
|
|
|
it('round-trips the fallback marker helper', () => {
|
|
expect(linuxGpuFallbackMarker('gpu-launch-failure', '0.21.5')).toEqual({
|
|
state: 'fallback',
|
|
reason: 'gpu-launch-failure',
|
|
version: '0.21.5'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('linuxGpuChildDeathPath', () => {
|
|
const SIGTERM_DEATH = { type: 'GPU', reason: 'crashed', exitCode: 143, signalName: 'SIGTERM' }
|
|
|
|
it('prefers the sandbox ladder on the #121954 SIGTERM signature', () => {
|
|
expect(linuxGpuChildDeathPath({ platform: 'linux', details: SIGTERM_DEATH })).toBe('no-sandbox')
|
|
})
|
|
|
|
it('falls to software when the sandbox relaunch was already spent (relapse)', () => {
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: SIGTERM_DEATH,
|
|
sandboxRelaunchAttempted: true
|
|
})
|
|
).toBe('disable-gpu')
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: SIGTERM_DEATH,
|
|
alreadyNoSandbox: true
|
|
})
|
|
).toBe('disable-gpu')
|
|
})
|
|
|
|
it('sends non-signature GPU failures straight to software', () => {
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'launch-failure', exitCode: 1002 }
|
|
})
|
|
).toBe('disable-gpu')
|
|
expect(
|
|
linuxGpuChildDeathPath({ platform: 'linux', details: { type: 'GPU', reason: 'crashed', exitCode: 139 } })
|
|
).toBe('disable-gpu')
|
|
})
|
|
|
|
it('produces at most one decision: both ladders spent or already on means none', () => {
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: SIGTERM_DEATH,
|
|
sandboxRelaunchAttempted: true,
|
|
softwareRelaunchAttempted: true
|
|
})
|
|
).toBeNull()
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'crashed' },
|
|
alreadySoftware: true
|
|
})
|
|
).toBeNull()
|
|
})
|
|
|
|
it('ignores non-GPU deaths and other platforms', () => {
|
|
expect(linuxGpuChildDeathPath({ platform: 'linux', details: { type: 'Renderer', reason: 'crashed' } })).toBeNull()
|
|
expect(linuxGpuChildDeathPath({ platform: 'darwin', details: SIGTERM_DEATH })).toBeNull()
|
|
expect(linuxGpuChildDeathPath({ platform: 'linux' })).toBeNull()
|
|
})
|
|
|
|
it('requires the full SIGTERM signature for the sandbox path, not just exit 143', () => {
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'crashed', exitCode: 143, signalName: 'SIGKILL' }
|
|
})
|
|
).toBe('disable-gpu')
|
|
// #121954's contract (shouldRelaunchForGpuSandboxCrash) checks the
|
|
// signal signature only: a GPU child SIGTERMed by Chromium is its own
|
|
// "never became usable" shutdown — the reason string carries no extra
|
|
// signal (an OOM would arrive as SIGKILL, above).
|
|
expect(
|
|
linuxGpuChildDeathPath({
|
|
platform: 'linux',
|
|
details: { type: 'GPU', reason: 'oom', exitCode: 143, signalName: 'SIGTERM' }
|
|
})
|
|
).toBe('no-sandbox')
|
|
})
|
|
})
|
|
|
|
describe('disableGpuSwitchNeededForReason', () => {
|
|
it('spawn-blocks the GPU process only for the explicit env override', () => {
|
|
expect(disableGpuSwitchNeededForReason('override (HERMES_DESKTOP_DISABLE_GPU)')).toBe(true)
|
|
expect(disableGpuSwitchNeededForReason('ssh-session')).toBe(false)
|
|
expect(disableGpuSwitchNeededForReason('vnc-session')).toBe(false)
|
|
expect(disableGpuSwitchNeededForReason(null)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldEngageSilentGpuRetryFallback', () => {
|
|
it('engages only on Linux, after the grace window, with no GPU child, not already software', () => {
|
|
expect(shouldEngageSilentGpuRetryFallback({ platform: 'linux', gpuChildPresent: false, graceElapsed: true })).toBe(
|
|
true
|
|
)
|
|
expect(shouldEngageSilentGpuRetryFallback({ platform: 'linux', gpuChildPresent: true, graceElapsed: true })).toBe(
|
|
false
|
|
)
|
|
expect(shouldEngageSilentGpuRetryFallback({ platform: 'linux', gpuChildPresent: false, graceElapsed: false })).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
shouldEngageSilentGpuRetryFallback({
|
|
platform: 'linux',
|
|
gpuChildPresent: false,
|
|
graceElapsed: true,
|
|
alreadySoftware: true
|
|
})
|
|
).toBe(false)
|
|
expect(shouldEngageSilentGpuRetryFallback({ platform: 'darwin', gpuChildPresent: false, graceElapsed: true })).toBe(
|
|
false
|
|
)
|
|
})
|
|
})
|