mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042) (#16868)
* feat(terminal): weight-layer forensics for the bold-collapse bug (STA-4042) Field instrumentation to name the writer behind regular-text-renders-bold: - metric-weight-change crumbs at the writePaneMetricOptions funnel (prev/next/reason; weights never change in normal operation) - terminal-weight-parity-mismatch audit on every visibility resume - sentinel weightProbe capture fields: live options vs atlas captured config vs renderer-buffer bold census - Cmd/Ctrl+Shift+click unconditional capture (no divergence gate, no recovery) for states the missing-ink detector cannot see - patched addon-webgl ctx.font readback probe: detects failed font assignments that rasterize glyphs at a stale weight * fix(terminal): treat canvas weight-700-serializes-as-bold as a match in the atlas font probe Found by live validation: Chromium's ctx.font getter normalizes numeric 700 to the keyword 'bold', which made every legitimate bold rasterization count as a failed assignment (124 false positives in one session). * chore: update patch hash for the font-probe normalization fix * fix(terminal): bound bold glitch diagnostics * fix(terminal): cover serialized WebGL probe state * feat(settings): hidden staff toggle to arm terminal render diagnostics Replaces the reserved hidden-experimental placeholder slot with a real switch (Shift-click the Experimental sidebar entry to reveal). It arms and disarms the render-desync capture sentinel live — no localStorage incantation, no reload — for the bold-glitch investigation. The passive probes stay always-on; only the capture gestures are gated. * fix(settings): make render diagnostics disarm exact * chore(settings): rename hidden group to 'Hidden experimental settings', drop its description * feat(settings): unlock hidden experimental group via Option-click on the Experimental page title Replaces the Shift-click-sidebar unlock with the Updates-header idiom: Option-click the Experimental page title toggles the hidden group. Removes the now-unused click-modifier plumbing from the settings sidebar.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -17,11 +17,11 @@ describe('Electron runtime package contract', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
expect(patch.match(/\^\(\?:\[1-8\]\\d\{2\}\|900\)\$/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
|
||||
@@ -434,6 +434,20 @@ describe('check-mode reporting', () => {
|
||||
// These run without network or a build, so ordinary `pnpm test` catches the two
|
||||
// desyncs that would otherwise only surface in the heavy xterm_patch_sync job.
|
||||
describe('committed xterm patch artifacts', () => {
|
||||
it('normalizes Chromium font-weight serialization in every WebGL runtime copy', async () => {
|
||||
const patch = await readFile(
|
||||
path.join(REPO_ROOT, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
expect(patch).not.toMatch(/desiredWeight !== '400'|orcaProbeWeight!=="400"/)
|
||||
expect(
|
||||
patch.match(/(?:actualWeightToken === 'normal'|orcaActualWeightToken==="normal")/g)
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
patch.match(/(?:actualWeight !== desiredWeight|orcaActualWeight!==orcaProbeWeight)/g)
|
||||
).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('records the lockfile hash pnpm derives from the patch file', async () => {
|
||||
const manifest = JSON.parse(await readFile(MANIFEST_PATH, 'utf8'))
|
||||
const lockfile = await readFile(path.join(REPO_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
|
||||
Generated
+3
-3
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c
|
||||
path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7
|
||||
@@ -229,7 +229,7 @@ importers:
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
@@ -9638,7 +9638,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ type ExperimentalPaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
/** Hidden-experimental group is only rendered once the user has unlocked
|
||||
* it via Shift-clicking the Experimental sidebar entry. */
|
||||
* it via Option-clicking the Experimental page title. */
|
||||
hiddenExperimentalUnlocked?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { HiddenExperimentalGroup } from './HiddenExperimentalGroup'
|
||||
|
||||
const isArmed = vi.fn()
|
||||
const setArmed = vi.fn()
|
||||
vi.mock('../terminal-pane/terminal-render-desync-trigger', () => ({
|
||||
isTerminalRenderDesyncSentinelArmed: (...args: unknown[]) => isArmed(...args),
|
||||
setTerminalRenderDesyncSentinelArmed: (...args: unknown[]) => setArmed(...args)
|
||||
}))
|
||||
|
||||
describe('HiddenExperimentalGroup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isArmed.mockReturnValue(false)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderDiagnosticsSwitch(): HTMLElement {
|
||||
return screen.getByRole('switch', { name: 'Terminal render diagnostics' })
|
||||
}
|
||||
|
||||
it('initializes the render-diagnostics switch from the armed state', () => {
|
||||
isArmed.mockReturnValue(true)
|
||||
render(<HiddenExperimentalGroup />)
|
||||
|
||||
expect(renderDiagnosticsSwitch().getAttribute('data-state')).toBe('checked')
|
||||
})
|
||||
|
||||
it('arms and disarms the capture sentinel through the switch', () => {
|
||||
render(<HiddenExperimentalGroup />)
|
||||
expect(renderDiagnosticsSwitch().getAttribute('data-state')).toBe('unchecked')
|
||||
|
||||
fireEvent.click(renderDiagnosticsSwitch())
|
||||
expect(setArmed).toHaveBeenCalledWith(true)
|
||||
expect(renderDiagnosticsSwitch().getAttribute('data-state')).toBe('checked')
|
||||
|
||||
fireEvent.click(renderDiagnosticsSwitch())
|
||||
expect(setArmed).toHaveBeenCalledWith(false)
|
||||
expect(renderDiagnosticsSwitch().getAttribute('data-state')).toBe('unchecked')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Mac', '⌘-click', '⇧⌘-click'],
|
||||
['Windows', 'Ctrl+click', 'Shift+Ctrl+click']
|
||||
])('renders the platform-native capture chords on %s', (userAgent, sampling, capture) => {
|
||||
vi.stubGlobal('navigator', { userAgent })
|
||||
render(<HiddenExperimentalGroup />)
|
||||
|
||||
expect(screen.getByText(new RegExp(sampling.replace('+', '\\+')))).toBeTruthy()
|
||||
expect(screen.getByText(new RegExp(capture.replaceAll('+', '\\+')))).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,52 +1,62 @@
|
||||
import { useState } from 'react'
|
||||
import { Label } from '../ui/label'
|
||||
import { Switch } from '../ui/switch'
|
||||
import {
|
||||
isTerminalRenderDesyncSentinelArmed,
|
||||
setTerminalRenderDesyncSentinelArmed
|
||||
} from '../terminal-pane/terminal-render-desync-trigger'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
|
||||
// Why: anything in this group is deliberately unfinished or staff-only. The
|
||||
// orange treatment (header tint, label colors) is the shared visual signal
|
||||
// for hidden-experimental items so future entries inherit the same
|
||||
// affordance without another round of styling decisions.
|
||||
export function HiddenExperimentalGroup(): React.JSX.Element {
|
||||
const isMac = getShortcutPlatform() === 'darwin'
|
||||
const [renderDiagnosticsArmed, setRenderDiagnosticsArmed] = useState(
|
||||
isTerminalRenderDesyncSentinelArmed
|
||||
)
|
||||
const onRenderDiagnosticsChange = (armed: boolean): void => {
|
||||
setTerminalRenderDesyncSentinelArmed(armed)
|
||||
setRenderDiagnosticsArmed(armed)
|
||||
}
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border border-orange-500/40 bg-orange-500/5 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-sm font-semibold text-orange-500 dark:text-orange-300">
|
||||
{translate(
|
||||
'auto.components.settings.HiddenExperimentalGroup.3e9e827ca5',
|
||||
'Hidden experimental'
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-xs text-orange-500/80 dark:text-orange-300/80">
|
||||
{translate(
|
||||
'auto.components.settings.HiddenExperimentalGroup.232cf83de8',
|
||||
'Unlisted toggles for internal testing. Nothing here is supported.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-orange-500 dark:text-orange-300">
|
||||
{translate(
|
||||
'auto.components.settings.HiddenExperimentalGroup.f2c81d904a',
|
||||
'Hidden experimental settings'
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 rounded-md border border-orange-500/30 bg-orange-500/10 px-3 py-2.5">
|
||||
<div className="min-w-0 shrink space-y-0.5">
|
||||
<Label className="text-orange-600 dark:text-orange-300">
|
||||
{translate(
|
||||
'auto.components.settings.HiddenExperimentalGroup.d0f914a528',
|
||||
'Placeholder toggle'
|
||||
'auto.components.settings.HiddenExperimentalGroup.b09f24a51d',
|
||||
'Terminal render diagnostics'
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-orange-600/80 dark:text-orange-300/80">
|
||||
{translate(
|
||||
'auto.components.settings.HiddenExperimentalGroup.1014ddbfaf',
|
||||
'Does nothing today. Reserved as the first slot for hidden experimental options.'
|
||||
'auto.components.settings.HiddenExperimentalGroup.7c4e18d2f6',
|
||||
'Arms the bold-glitch capture gestures on this machine: {{samplingShortcut}} starts a sampling burst; {{captureShortcut}} captures pane evidence to disk.',
|
||||
{
|
||||
samplingShortcut: isMac ? '⌘-click' : 'Ctrl+click',
|
||||
captureShortcut: isMac ? '⇧⌘-click' : 'Shift+Ctrl+click'
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={translate(
|
||||
'auto.components.settings.HiddenExperimentalGroup.d0f914a528',
|
||||
'Placeholder toggle'
|
||||
'auto.components.settings.HiddenExperimentalGroup.b09f24a51d',
|
||||
'Terminal render diagnostics'
|
||||
)}
|
||||
checked={false}
|
||||
className="border-orange-500/40 data-[state=unchecked]:bg-orange-500/20 disabled:opacity-70"
|
||||
disabled
|
||||
checked={renderDiagnosticsArmed}
|
||||
className="border-orange-500/40 data-[state=unchecked]:bg-orange-500/20"
|
||||
onCheckedChange={onRenderDiagnosticsChange}
|
||||
thumbClassName="data-[state=unchecked]:bg-orange-200 dark:data-[state=unchecked]:bg-orange-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -389,7 +389,7 @@ function Settings(): React.JSX.Element {
|
||||
const [hasUnsavedBranchPromptChanges, setHasUnsavedBranchPromptChanges] = useState(false)
|
||||
const [sourceControlAiPromptDiscardSignal, setSourceControlAiPromptDiscardSignal] = useState(0)
|
||||
const confirm = useConfirmationDialog()
|
||||
// Why: session-only (deliberately not persisted) unlock — Shift-click the Experimental entry reveals the hidden group.
|
||||
// Why: session-only (deliberately not persisted) unlock — Option-click the Experimental page title reveals the hidden group.
|
||||
const [hiddenExperimentalUnlocked, setHiddenExperimentalUnlocked] = useState(false)
|
||||
const contentScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
@@ -1108,17 +1108,10 @@ function Settings(): React.JSX.Element {
|
||||
])
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
async (
|
||||
sectionId: string,
|
||||
modifiers?: { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }
|
||||
): Promise<void> => {
|
||||
async (sectionId: string): Promise<void> => {
|
||||
if (sectionId !== activeSectionId && !(await confirmDiscardSourceControlAiPromptChanges())) {
|
||||
return
|
||||
}
|
||||
// Why: Shift-click the Experimental row unlocks the hidden power-user group (session-only).
|
||||
if (sectionId === 'experimental' && modifiers?.shiftKey) {
|
||||
setHiddenExperimentalUnlocked((previous) => !previous)
|
||||
}
|
||||
const container = contentScrollRef.current
|
||||
if (container) {
|
||||
container.scrollTo({ top: 0 })
|
||||
@@ -1844,6 +1837,13 @@ function Settings(): React.JSX.Element {
|
||||
'New features that are still taking shape. Give them a try.'
|
||||
)}
|
||||
searchEntries={getSectionSearchEntries('experimental')}
|
||||
// Why: Option-click the page title unlocks the hidden staff group
|
||||
// (session-only) — same idiom as Option-click on the Updates header.
|
||||
onTitleClick={(event) => {
|
||||
if (event.altKey) {
|
||||
setHiddenExperimentalUnlocked((previous) => !previous)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSectionMounted('experimental') ? (
|
||||
<ExperimentalPane
|
||||
|
||||
@@ -31,6 +31,9 @@ type SettingsSectionProps = {
|
||||
* section-scoped actions (e.g. "Import from Ghostty") that would otherwise
|
||||
* crowd the settings list as their own row. */
|
||||
headerAction?: React.ReactNode
|
||||
/** Click handler on the section title — the hook for hidden staff
|
||||
* affordances (Option-click reveals), mirroring the Updates header. */
|
||||
onTitleClick?: (event: React.MouseEvent<HTMLHeadingElement>) => void
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -45,7 +48,8 @@ export function SettingsSection({
|
||||
badgeAccessory,
|
||||
forceVisible = false,
|
||||
isActive,
|
||||
headerAction
|
||||
headerAction,
|
||||
onTitleClick
|
||||
}: SettingsSectionProps): React.JSX.Element | null {
|
||||
const query = useAppStore((state) => state.settingsSearchQuery)
|
||||
const activeFromContext = useContext(ActiveSettingsSectionContext)
|
||||
@@ -66,7 +70,10 @@ export function SettingsSection({
|
||||
<section id={id} data-settings-section={id} className={cn('scroll-mt-8 space-y-6', className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border/60 pb-5">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h2 className="flex flex-wrap items-center gap-2 text-2xl font-semibold leading-tight text-foreground">
|
||||
<h2
|
||||
className="flex flex-wrap items-center gap-2 text-2xl font-semibold leading-tight text-foreground"
|
||||
onClick={onTitleClick}
|
||||
>
|
||||
{title}
|
||||
{badge ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.05em] text-muted-foreground">
|
||||
|
||||
@@ -50,15 +50,7 @@ type SettingsSidebarProps = {
|
||||
searchInputRef?: RefObject<HTMLInputElement | null>
|
||||
searchAutoFocus?: boolean
|
||||
onBack: () => void
|
||||
onSelectSection: (
|
||||
sectionId: string,
|
||||
modifiers: {
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
shiftKey: boolean
|
||||
altKey: boolean
|
||||
}
|
||||
) => void
|
||||
onSelectSection: (sectionId: string) => void
|
||||
}
|
||||
|
||||
function SettingsSearchField({
|
||||
@@ -116,12 +108,7 @@ function isVisibleInstallStatus(
|
||||
type SettingsSetupGuideRowProps = {
|
||||
progress: SettingsSetupGuideProgress
|
||||
setupActive: boolean
|
||||
onSelect: (modifiers: {
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
shiftKey: boolean
|
||||
altKey: boolean
|
||||
}) => void
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
function SettingsSetupGuideNavRow({
|
||||
@@ -138,14 +125,7 @@ function SettingsSetupGuideNavRow({
|
||||
'Onboarding checklist, {{value0}} of {{value1}} done. Show setup guide.',
|
||||
{ value0: progress.doneCount, value1: progress.total }
|
||||
)}
|
||||
onClick={(event) =>
|
||||
onSelect({
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey
|
||||
})
|
||||
}
|
||||
onClick={() => onSelect()}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-worktree-sidebar-ring/50',
|
||||
setupActive
|
||||
@@ -238,7 +218,7 @@ export function SettingsSidebar({
|
||||
<SettingsSetupGuideNavRow
|
||||
progress={setupGuideProgress}
|
||||
setupActive={setupActive}
|
||||
onSelect={(modifiers) => onSelectSection('setup-guide', modifiers)}
|
||||
onSelect={() => onSelectSection('setup-guide')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -262,14 +242,7 @@ export function SettingsSidebar({
|
||||
key={section.id}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-current={isActive ? 'true' : undefined}
|
||||
onClick={(event) =>
|
||||
onSelectSection(section.id, {
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey
|
||||
})
|
||||
}
|
||||
onClick={() => onSelectSection(section.id)}
|
||||
className={navItemClassName(isActive)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
@@ -305,14 +278,7 @@ export function SettingsSidebar({
|
||||
key={section.id}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-current={isActive ? 'true' : undefined}
|
||||
onClick={(event) =>
|
||||
onSelectSection(section.id, {
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey
|
||||
})
|
||||
}
|
||||
onClick={() => onSelectSection(section.id)}
|
||||
className={navItemClassName(isActive)}
|
||||
>
|
||||
<RepoIconGlyph
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
} from '../../../../shared/pty-delivery-diagnostics'
|
||||
import {
|
||||
TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB,
|
||||
recordTerminalWebglDiagnostic,
|
||||
setTerminalWebglDiagnosticRecorder
|
||||
} from '../../../../shared/terminal-webgl-diagnostics'
|
||||
import { maybeStartTerminalRenderDesyncSentinel } from './terminal-render-desync-sentinel'
|
||||
import { maybeStartTerminalRenderDesyncSentinel } from './terminal-render-desync-trigger'
|
||||
|
||||
const rendererDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing()
|
||||
const ATLAS_FONT_PROBE_MISMATCH = 'atlas-font-probe-mismatch'
|
||||
const ATLAS_CRASH_MIRROR_INTERVAL_MS = 30_000
|
||||
let lastAtlasCrashMirrorAt = Number.NEGATIVE_INFINITY
|
||||
let suppressedAtlasCrashMirrors = 0
|
||||
|
||||
export function recordTerminalFreezeBreadcrumb(
|
||||
kind: string,
|
||||
@@ -34,9 +39,26 @@ export function recordTerminalFreezeBreadcrumb(
|
||||
// instrumentation, not absence of the event.
|
||||
setTerminalWebglDiagnosticRecorder((kind, detail) => {
|
||||
rendererDeliveryBreadcrumbs.record(kind, detail)
|
||||
if (kind === ATLAS_FONT_PROBE_MISMATCH) {
|
||||
const now = Date.now()
|
||||
if (now - lastAtlasCrashMirrorAt < ATLAS_CRASH_MIRROR_INTERVAL_MS) {
|
||||
suppressedAtlasCrashMirrors++
|
||||
return
|
||||
}
|
||||
lastAtlasCrashMirrorAt = now
|
||||
}
|
||||
// `kind` last: it is the coalescing discriminator, so a detail field of the
|
||||
// same name must not be able to shadow it.
|
||||
recordRendererCrashBreadcrumb(TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB, { ...detail, kind })
|
||||
recordRendererCrashBreadcrumb(TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB, {
|
||||
...detail,
|
||||
...(kind === ATLAS_FONT_PROBE_MISMATCH && suppressedAtlasCrashMirrors > 0
|
||||
? { rendererSuppressedSinceLast: suppressedAtlasCrashMirrors }
|
||||
: {}),
|
||||
kind
|
||||
})
|
||||
if (kind === ATLAS_FONT_PROBE_MISMATCH) {
|
||||
suppressedAtlasCrashMirrors = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the sentinel is a field-diagnostic that must be armable on production
|
||||
@@ -44,10 +66,26 @@ setTerminalWebglDiagnosticRecorder((kind, detail) => {
|
||||
// of any specific pane mounting first. No-op unless its localStorage flag is set.
|
||||
maybeStartTerminalRenderDesyncSentinel()
|
||||
|
||||
// Sink for the patched @xterm/addon-webgl atlas font probe: the atlas cannot
|
||||
// import Orca code, so it reports failed ctx.font assignments (the stuck-
|
||||
// rasterizer arm of the bold-collapse family) through this global. Crumbs are
|
||||
// coalesced upstream, so a rasterization storm cannot flood the report.
|
||||
type AtlasFontProbeMismatch = { desired?: string; actual?: string }
|
||||
;(globalThis as { __orcaAtlasFontProbe?: (mismatch: AtlasFontProbeMismatch) => void })[
|
||||
'__orcaAtlasFontProbe'
|
||||
] = (mismatch) => {
|
||||
recordTerminalWebglDiagnostic(ATLAS_FONT_PROBE_MISMATCH, {
|
||||
desired: mismatch?.desired ?? null,
|
||||
actual: mismatch?.actual ?? null
|
||||
})
|
||||
}
|
||||
|
||||
export function getTerminalFreezeBreadcrumbs(): PtyDeliveryBreadcrumb[] {
|
||||
return rendererDeliveryBreadcrumbs.snapshot()
|
||||
}
|
||||
|
||||
export function resetTerminalFreezeBreadcrumbsForTesting(): void {
|
||||
rendererDeliveryBreadcrumbs.reset()
|
||||
lastAtlasCrashMirrorAt = Number.NEGATIVE_INFINITY
|
||||
suppressedAtlasCrashMirrors = 0
|
||||
}
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import type { SentinelEvidence } from './terminal-render-desync-sentinel'
|
||||
|
||||
/**
|
||||
* Durable persistence for render-desync captures, split from the sentinel so
|
||||
* the detection loop, trigger gestures and storage lifecycles stay separately
|
||||
* readable. Failure contract: a failed write must leave the live pane intact —
|
||||
* recovering after a failed write would destroy the only evidence without
|
||||
* producing a durable capture, so callers gate recovery on the returned
|
||||
* directory.
|
||||
*/
|
||||
export async function persistCorruptEvidence(entry: SentinelEvidence): Promise<string | null> {
|
||||
const pngDataUrl = entry.livePngDataUrl
|
||||
const bufferText = entry.bufferText
|
||||
try {
|
||||
if (!pngDataUrl || bufferText == null) {
|
||||
throw new Error('Render-desync evidence payload was released before persistence')
|
||||
}
|
||||
const persisted = await window.api.app.writeTerminalRenderDesyncEvidence({
|
||||
captureId: entry.captureId,
|
||||
phase: 'corrupt',
|
||||
pngDataUrl,
|
||||
metadata: {
|
||||
paneKey: entry.paneKey,
|
||||
when: entry.when,
|
||||
divergence: entry.divergence,
|
||||
paused: entry.paused,
|
||||
trigger: entry.trigger,
|
||||
rendererState: entry.rendererState,
|
||||
weightProbe: entry.weightProbe,
|
||||
bufferText
|
||||
}
|
||||
})
|
||||
entry.persistedDirectory = persisted.directory
|
||||
return persisted.directory
|
||||
} catch (error) {
|
||||
console.error('[terminal] could not persist render-desync evidence; leaving pane intact', error)
|
||||
return null
|
||||
} finally {
|
||||
// Why: persistence owns a successful payload, while a failed write leaves
|
||||
// the live pane intact; neither path should retain duplicate full-canvas data.
|
||||
entry.livePngDataUrl = undefined
|
||||
entry.bufferText = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistHealedReference(
|
||||
captureId: string,
|
||||
canvas: HTMLCanvasElement
|
||||
): Promise<void> {
|
||||
try {
|
||||
await window.api.app.writeTerminalRenderDesyncEvidence({
|
||||
captureId,
|
||||
phase: 'healed',
|
||||
pngDataUrl: canvas.toDataURL(),
|
||||
metadata: { when: Date.now() }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[terminal] could not persist healed render reference', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function createCaptureId(paneKey: string): string {
|
||||
const panePart = paneKey.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
const nonce = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)
|
||||
return `${Date.now()}-${panePart}-${nonce}`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
measureDivergence,
|
||||
reachRenderInternals,
|
||||
releaseRenderDesyncReadback,
|
||||
type BufferLike,
|
||||
type SentinelRenderInternals
|
||||
@@ -73,3 +74,26 @@ describe('measureDivergence', () => {
|
||||
expect(createElement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reachRenderInternals', () => {
|
||||
it('reads the generation name emitted by the packaged WebGL bundle', () => {
|
||||
const terminal = {
|
||||
rows: 2,
|
||||
cols: 3,
|
||||
_core: {
|
||||
_renderService: {
|
||||
_renderer: {
|
||||
value: {
|
||||
_canvas: {} as HTMLCanvasElement,
|
||||
_charAtlas: { _clearModelGeneration: 7, pages: [] },
|
||||
_themeService: { colors: { background: { rgba: 0 } } },
|
||||
dimensions: { device: { cell: { width: 8, height: 16 } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(reachRenderInternals(terminal)?.rendererState.atlasClearModelGeneration).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,7 @@ export function reachRenderInternals(terminal: unknown): SentinelRenderInternals
|
||||
_canvas?: HTMLCanvasElement
|
||||
_charAtlas?: {
|
||||
clearModelGeneration?: number
|
||||
_clearModelGeneration?: number
|
||||
pages?: { version?: number }[]
|
||||
}
|
||||
_model?: { lineLengths?: number[] | Uint32Array }
|
||||
@@ -107,7 +108,10 @@ export function reachRenderInternals(terminal: unknown): SentinelRenderInternals
|
||||
],
|
||||
rendererState: {
|
||||
atlasPages: renderer._charAtlas.pages?.length ?? -1,
|
||||
atlasClearModelGeneration: renderer._charAtlas.clearModelGeneration ?? null,
|
||||
atlasClearModelGeneration:
|
||||
renderer._charAtlas.clearModelGeneration ??
|
||||
renderer._charAtlas._clearModelGeneration ??
|
||||
null,
|
||||
atlasPageVersions: renderer._charAtlas.pages?.map((page) => page.version ?? -1) ?? [],
|
||||
glyphLastSeenClearModelGeneration: glyphRenderer?._lastSeenClearModelGeneration ?? null,
|
||||
glyphTextureVersions:
|
||||
|
||||
@@ -25,11 +25,14 @@ vi.mock('../../../../shared/terminal-webgl-diagnostics', () => ({
|
||||
|
||||
import {
|
||||
getRenderDesyncEvidence,
|
||||
maybeStartTerminalRenderDesyncSentinel,
|
||||
RENDER_DESYNC_SENTINEL_FLAG,
|
||||
sampleRenderDesyncOnce,
|
||||
stopTerminalRenderDesyncSentinelForTesting
|
||||
} from './terminal-render-desync-sentinel'
|
||||
import {
|
||||
maybeStartTerminalRenderDesyncSentinel,
|
||||
RENDER_DESYNC_SENTINEL_FLAG,
|
||||
stopTerminalRenderDesyncTriggerForTesting
|
||||
} from './terminal-render-desync-trigger'
|
||||
|
||||
function fakePane(overrides: { paused?: boolean } = {}) {
|
||||
const refreshRows = vi.fn()
|
||||
@@ -94,6 +97,7 @@ describe('terminal-render-desync-sentinel', () => {
|
||||
})
|
||||
afterEach(() => {
|
||||
stopTerminalRenderDesyncSentinelForTesting()
|
||||
stopTerminalRenderDesyncTriggerForTesting()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -123,7 +127,14 @@ describe('terminal-render-desync-sentinel', () => {
|
||||
expect(writeTerminalRenderDesyncEvidence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
phase: 'corrupt',
|
||||
metadata: expect.objectContaining({ bufferText: expect.stringContaining('x') })
|
||||
metadata: expect.objectContaining({
|
||||
bufferText: expect.stringContaining('x'),
|
||||
trigger: 'divergence',
|
||||
weightProbe: expect.objectContaining({
|
||||
totalTextCells: expect.any(Number),
|
||||
boldTextCells: expect.any(Number)
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(getRenderDesyncEvidence()).toHaveLength(1)
|
||||
@@ -168,9 +179,25 @@ describe('terminal-render-desync-sentinel', () => {
|
||||
sampleWith(divergenceOf(manyCells(0)), false, `m1:p${pane}`)
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(writeTerminalRenderDesyncEvidence).toHaveBeenCalledTimes(4))
|
||||
// 4 capture recoveries + 1 budget-exhausted recovery; waiting on the reset
|
||||
// count (not the write count) keeps this robust to persist microtask depth.
|
||||
await vi.waitFor(() => expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(5))
|
||||
expect(writeTerminalRenderDesyncEvidence).toHaveBeenCalledTimes(4)
|
||||
expect(getRenderDesyncEvidence()).toHaveLength(4)
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('does not spend the capture budget on failed persistence attempts', async () => {
|
||||
writeTerminalRenderDesyncEvidence.mockRejectedValue(new Error('disk unavailable'))
|
||||
|
||||
for (let pane = 1; pane <= 5; pane++) {
|
||||
sampleWith(divergenceOf(manyCells(0)), false, `m1:p${pane}`)
|
||||
sampleWith(divergenceOf(manyCells(0)), false, `m1:p${pane}`)
|
||||
await vi.waitFor(() => expect(writeTerminalRenderDesyncEvidence).toHaveBeenCalledTimes(pane))
|
||||
await vi.waitFor(() => expect(getRenderDesyncEvidence()).toHaveLength(0))
|
||||
}
|
||||
|
||||
expect(getRenderDesyncEvidence()).toHaveLength(0)
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets tracking for paused panes instead of sampling them', () => {
|
||||
@@ -190,9 +217,11 @@ describe('terminal-render-desync-sentinel', () => {
|
||||
setItem: (k: string, v: string) => storage.set(k, v)
|
||||
})
|
||||
maybeStartTerminalRenderDesyncSentinel()
|
||||
expect(documentAddEventListener).not.toHaveBeenCalled()
|
||||
const { pane } = fakePane()
|
||||
const target = new FakeNode()
|
||||
expect(forEachLivePaneForDesyncSentinel).not.toHaveBeenCalled()
|
||||
expect(writeTerminalRenderDesyncEvidence).not.toHaveBeenCalled()
|
||||
|
||||
storage.set(RENDER_DESYNC_SENTINEL_FLAG, '1')
|
||||
maybeStartTerminalRenderDesyncSentinel()
|
||||
@@ -215,4 +244,143 @@ describe('terminal-render-desync-sentinel', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shift-modifier-click captures immediately and leaves the pane unrecovered', async () => {
|
||||
const storage = new Map<string, string>([[RENDER_DESYNC_SENTINEL_FLAG, '1']])
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => storage.set(k, v)
|
||||
})
|
||||
maybeStartTerminalRenderDesyncSentinel()
|
||||
const { pane } = fakePane()
|
||||
;(
|
||||
pane.terminal as { element: { contains: ReturnType<typeof vi.fn> } }
|
||||
).element.contains.mockReturnValue(true)
|
||||
forEachLivePaneForDesyncSentinel.mockImplementation(
|
||||
(visit: (key: string, pane: unknown) => void) => visit('m1:p1', pane)
|
||||
)
|
||||
const listener = documentAddEventListener.mock.calls.at(-1)?.[1]
|
||||
listener({ button: 0, metaKey: true, ctrlKey: true, shiftKey: true, target: new FakeNode() })
|
||||
|
||||
await vi.waitFor(() => expect(writeTerminalRenderDesyncEvidence).toHaveBeenCalledTimes(1))
|
||||
expect(writeTerminalRenderDesyncEvidence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
phase: 'corrupt',
|
||||
metadata: expect.objectContaining({ trigger: 'manual' })
|
||||
})
|
||||
)
|
||||
expect(recordTerminalWebglDiagnostic).toHaveBeenCalledWith(
|
||||
'webgl-render-desync-manual-capture',
|
||||
expect.objectContaining({ paneKey: 'm1:p1' })
|
||||
)
|
||||
// The captured state must stay on screen for further pokes: no recovery.
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sentinel arming surface', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
stopTerminalRenderDesyncSentinelForTesting()
|
||||
stopTerminalRenderDesyncTriggerForTesting()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('arms live on enable and removes the listener on disable', async () => {
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => storage.set(k, v),
|
||||
removeItem: (k: string) => storage.delete(k)
|
||||
})
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: documentAddEventListener,
|
||||
removeEventListener: documentRemoveEventListener
|
||||
})
|
||||
vi.stubGlobal('navigator', { userAgent: 'Mac' })
|
||||
const { isTerminalRenderDesyncSentinelArmed, setTerminalRenderDesyncSentinelArmed } =
|
||||
await import('./terminal-render-desync-trigger')
|
||||
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(false)
|
||||
setTerminalRenderDesyncSentinelArmed(true)
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(true)
|
||||
// Live arm: the mouseup listener is registered without a reload.
|
||||
expect(documentAddEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function), true)
|
||||
|
||||
setTerminalRenderDesyncSentinelArmed(false)
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(false)
|
||||
expect(documentRemoveEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function), true)
|
||||
})
|
||||
|
||||
it('keeps the live arming state when storage is unavailable', async () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('storage unavailable')
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: documentAddEventListener,
|
||||
removeEventListener: documentRemoveEventListener
|
||||
})
|
||||
const { isTerminalRenderDesyncSentinelArmed, setTerminalRenderDesyncSentinelArmed } =
|
||||
await import('./terminal-render-desync-trigger')
|
||||
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(false)
|
||||
setTerminalRenderDesyncSentinelArmed(true)
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(true)
|
||||
expect(documentAddEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function), true)
|
||||
|
||||
setTerminalRenderDesyncSentinelArmed(false)
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(false)
|
||||
expect(documentRemoveEventListener).toHaveBeenCalledWith('mouseup', expect.any(Function), true)
|
||||
})
|
||||
|
||||
it('stops an active sampling burst when disarmed', async () => {
|
||||
vi.useFakeTimers()
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => storage.set(k, v),
|
||||
removeItem: (k: string) => storage.delete(k)
|
||||
})
|
||||
vi.stubGlobal('document', {
|
||||
addEventListener: documentAddEventListener,
|
||||
removeEventListener: documentRemoveEventListener
|
||||
})
|
||||
vi.stubGlobal('navigator', { userAgent: 'Mac' })
|
||||
vi.stubGlobal('Node', FakeNode)
|
||||
const { isTerminalRenderDesyncSentinelArmed, setTerminalRenderDesyncSentinelArmed } =
|
||||
await import('./terminal-render-desync-trigger')
|
||||
const { pane } = fakePane()
|
||||
;(
|
||||
pane.terminal as never as {
|
||||
_core: { _renderService: { _renderer: { value: { _canvas: { width: number } } } } }
|
||||
}
|
||||
)._core._renderService._renderer.value._canvas.width = 0
|
||||
;(
|
||||
pane.terminal as { element: { contains: ReturnType<typeof vi.fn> } }
|
||||
).element.contains.mockReturnValue(true)
|
||||
forEachLivePaneForDesyncSentinel.mockImplementation(
|
||||
(visit: (key: string, pane: unknown) => void) => visit('m1:p1', pane)
|
||||
)
|
||||
|
||||
setTerminalRenderDesyncSentinelArmed(true)
|
||||
const listener = documentAddEventListener.mock.calls.at(-1)?.[1]
|
||||
listener({ button: 0, metaKey: true, target: new FakeNode() })
|
||||
expect(forEachLivePaneForDesyncSentinel).toHaveBeenCalledTimes(2)
|
||||
|
||||
setTerminalRenderDesyncSentinelArmed(false)
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(isTerminalRenderDesyncSentinelArmed()).toBe(false)
|
||||
expect(forEachLivePaneForDesyncSentinel).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
forEachLivePaneForDesyncSentinel,
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
} from '@/lib/pane-manager/pane-manager-registry'
|
||||
import {
|
||||
createCaptureId,
|
||||
persistCorruptEvidence,
|
||||
persistHealedReference
|
||||
} from './terminal-render-desync-evidence-persistence'
|
||||
import {
|
||||
activeBuffer,
|
||||
bufferSnapshot,
|
||||
@@ -13,6 +18,10 @@ import {
|
||||
type SentinelRendererState,
|
||||
type SentinelRenderInternals
|
||||
} from './terminal-render-desync-frame'
|
||||
import {
|
||||
readSentinelWeightProbe,
|
||||
type SentinelWeightProbe
|
||||
} from './terminal-render-desync-weight-probe'
|
||||
|
||||
/**
|
||||
* Flag-gated render-desync sentinel for WebGL terminal panes.
|
||||
@@ -25,12 +34,13 @@ import {
|
||||
* failure. A confirmed trip writes the pixels, buffer, and atlas/model versions
|
||||
* to local app data before running the shared-atlas recovery.
|
||||
*
|
||||
* Off by default; enabled via localStorage so a production build can arm it
|
||||
* from DevTools without a settings-schema change:
|
||||
* localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
|
||||
* Cmd/Ctrl+Shift+click (see terminal-render-desync-trigger.ts) captures the
|
||||
* clicked pane immediately and unconditionally — no divergence gate and no
|
||||
* recovery afterwards — for states the ink detector cannot see, e.g. the
|
||||
* bold-collapse bug (STA-4042 family) where every cell has ink but regular
|
||||
* text rasterized at the bold weight.
|
||||
*/
|
||||
|
||||
export const RENDER_DESYNC_SENTINEL_FLAG = 'orca:render-desync-sentinel'
|
||||
const SAMPLE_INTERVAL_MS = 250
|
||||
const SAMPLE_BURST_MS = 10_000
|
||||
// A real desync is pinned to fixed screen cells; scroll/frame lag moves around.
|
||||
@@ -44,9 +54,11 @@ export type SentinelEvidence = {
|
||||
captureId: string
|
||||
paneKey: string
|
||||
when: number
|
||||
trigger: 'divergence' | 'manual'
|
||||
divergence: { textCells: number; missing: number; missPct: number }
|
||||
paused: boolean
|
||||
rendererState: SentinelRendererState
|
||||
weightProbe: SentinelWeightProbe
|
||||
livePngDataUrl?: string
|
||||
bufferText?: string
|
||||
persistedDirectory?: string
|
||||
@@ -63,7 +75,6 @@ const healedCaptureTimeoutIds = new Set<ReturnType<typeof setTimeout>>()
|
||||
const evidence: SentinelEvidence[] = []
|
||||
let burstIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let burstTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let clickListener: ((event: MouseEvent) => void) | null = null
|
||||
let burstTerminal: unknown = null
|
||||
|
||||
export function getRenderDesyncEvidence(): SentinelEvidence[] {
|
||||
@@ -129,76 +140,122 @@ export function sampleRenderDesyncOnce(
|
||||
// Keep recovery available after the per-session evidence budget is spent.
|
||||
console.warn(`[terminal] render desync detected on pane ${paneKey}; capture budget exhausted`)
|
||||
resetAndRefreshAllTerminalWebglAtlases('render-desync')
|
||||
stopSampleBurst()
|
||||
stopRenderDesyncSampleBurst()
|
||||
return
|
||||
}
|
||||
pendingPaneKeys.add(paneKey)
|
||||
const entry: SentinelEvidence = {
|
||||
captureId: createCaptureId(paneKey),
|
||||
paneKey,
|
||||
when: Date.now(),
|
||||
divergence: {
|
||||
textCells: divergence.textCells,
|
||||
missing: divergence.missing,
|
||||
missPct: divergence.missPct
|
||||
},
|
||||
paused: internals.isPaused,
|
||||
rendererState: internals.rendererState,
|
||||
livePngDataUrl: internals.canvas.toDataURL(),
|
||||
bufferText: bufferSnapshot(buffer, internals.rows)
|
||||
}
|
||||
evidence.push(entry)
|
||||
const entry = buildEvidenceEntry(paneKey, terminal, internals, divergence, 'divergence')
|
||||
console.warn(
|
||||
`[terminal] render desync detected on pane ${paneKey} ` +
|
||||
`(${divergence.missing}/${divergence.textCells} cells, ${divergence.missPct.toFixed(1)}%) — persisting evidence`
|
||||
)
|
||||
void persistEvidenceThenRecover(entry, internals)
|
||||
void persistEntry(entry, internals, { recover: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function maybeStartTerminalRenderDesyncSentinel(): void {
|
||||
if (clickListener != null) {
|
||||
/**
|
||||
* Unconditional capture of one pane, bypassing the divergence detector. No
|
||||
* recovery afterwards: the caller wants the broken state left on screen to
|
||||
* poke at, and recovery would also destroy any not-yet-understood layer state.
|
||||
*/
|
||||
export function captureRenderDesyncNow(paneKey: string, pane: unknown): void {
|
||||
const terminal = (pane as SentinelPane).terminal
|
||||
if (pendingPaneKeys.has(paneKey) || evidence.length >= MAX_EVIDENCE_ENTRIES) {
|
||||
console.warn(`[terminal] manual desync capture skipped for ${paneKey}: budget or in flight`)
|
||||
return
|
||||
}
|
||||
let enabled = false
|
||||
const internals = reachRenderInternals(terminal)
|
||||
if (!internals) {
|
||||
console.warn(`[terminal] manual desync capture failed for ${paneKey}: no renderer internals`)
|
||||
return
|
||||
}
|
||||
// Why tolerant: the manual gesture must produce a capture even when the
|
||||
// canvas readback path fails — the PNG and probe fields are the payload.
|
||||
let measured: ReturnType<typeof measureDivergence> = null
|
||||
try {
|
||||
enabled = globalThis.localStorage?.getItem(RENDER_DESYNC_SENTINEL_FLAG) === '1'
|
||||
const buffer = activeBuffer(terminal)
|
||||
measured = buffer && measureDivergence(internals, buffer)
|
||||
} catch {
|
||||
enabled = false
|
||||
measured = null
|
||||
}
|
||||
if (!enabled) {
|
||||
const divergence = measured ?? {
|
||||
textCells: 0,
|
||||
missing: 0,
|
||||
missPct: 0,
|
||||
missingCells: new Set<number>()
|
||||
}
|
||||
const entry = buildEvidenceEntry(paneKey, terminal, internals, divergence, 'manual')
|
||||
recordTerminalWebglDiagnostic('webgl-render-desync-manual-capture', {
|
||||
paneKey,
|
||||
boldTextCells: entry.weightProbe.boldTextCells,
|
||||
totalTextCells: entry.weightProbe.totalTextCells,
|
||||
optionsFontWeight: entry.weightProbe.optionsFontWeight,
|
||||
atlasConfigFontWeight: entry.weightProbe.atlasConfigFontWeight
|
||||
})
|
||||
console.warn(`[terminal] manual render-desync capture on pane ${paneKey} — persisting evidence`)
|
||||
void persistEntry(entry, internals, { recover: false })
|
||||
}
|
||||
|
||||
function buildEvidenceEntry(
|
||||
paneKey: string,
|
||||
terminal: unknown,
|
||||
internals: SentinelRenderInternals,
|
||||
divergence: { textCells: number; missing: number; missPct: number },
|
||||
trigger: SentinelEvidence['trigger']
|
||||
): SentinelEvidence {
|
||||
pendingPaneKeys.add(paneKey)
|
||||
const buffer = activeBuffer(terminal)
|
||||
const entry: SentinelEvidence = {
|
||||
captureId: createCaptureId(paneKey),
|
||||
paneKey,
|
||||
when: Date.now(),
|
||||
trigger,
|
||||
divergence: {
|
||||
textCells: divergence.textCells,
|
||||
missing: divergence.missing,
|
||||
missPct: divergence.missPct
|
||||
},
|
||||
paused: internals.isPaused,
|
||||
rendererState: internals.rendererState,
|
||||
weightProbe: readSentinelWeightProbe(terminal, buffer, internals.rows, internals.cols),
|
||||
livePngDataUrl: internals.canvas.toDataURL(),
|
||||
bufferText: buffer ? bufferSnapshot(buffer, internals.rows) : ''
|
||||
}
|
||||
evidence.push(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function persistEntry(
|
||||
entry: SentinelEvidence,
|
||||
internals: SentinelRenderInternals,
|
||||
{ recover }: { recover: boolean }
|
||||
): Promise<void> {
|
||||
const directory = await persistCorruptEvidence(entry)
|
||||
if (directory == null) {
|
||||
// Why: a failed write must leave the bad pixels intact; recovering here
|
||||
// would destroy the only evidence without producing a durable capture.
|
||||
const entryIndex = evidence.indexOf(entry)
|
||||
if (entryIndex !== -1) {
|
||||
evidence.splice(entryIndex, 1)
|
||||
}
|
||||
pendingPaneKeys.delete(entry.paneKey)
|
||||
return
|
||||
}
|
||||
clickListener = (event) => {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
if (event.button !== 0 || (isMac ? !event.metaKey : !event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
return
|
||||
}
|
||||
let clickedTerminal: unknown = null
|
||||
forEachLivePaneForDesyncSentinel((_paneKey, pane) => {
|
||||
const terminal = (pane as SentinelPane).terminal as { element?: HTMLElement }
|
||||
if (terminal.element?.contains(target)) {
|
||||
clickedTerminal = terminal
|
||||
}
|
||||
})
|
||||
if (clickedTerminal) {
|
||||
startSampleBurst(clickedTerminal)
|
||||
}
|
||||
if (!recover) {
|
||||
pendingPaneKeys.delete(entry.paneKey)
|
||||
return
|
||||
}
|
||||
document.addEventListener('mouseup', clickListener, true)
|
||||
console.warn('[terminal] render-desync sentinel armed (10s post-link bursts)')
|
||||
resetAndRefreshAllTerminalWebglAtlases('render-desync')
|
||||
const timeoutId = setTimeout(() => {
|
||||
healedCaptureTimeoutIds.delete(timeoutId)
|
||||
void persistHealedReference(entry.captureId, internals.canvas).finally(() =>
|
||||
pendingPaneKeys.delete(entry.paneKey)
|
||||
)
|
||||
}, SAMPLE_INTERVAL_MS)
|
||||
healedCaptureTimeoutIds.add(timeoutId)
|
||||
}
|
||||
|
||||
export function stopTerminalRenderDesyncSentinelForTesting(): void {
|
||||
stopSampleBurst()
|
||||
if (clickListener != null) {
|
||||
document.removeEventListener('mouseup', clickListener, true)
|
||||
clickListener = null
|
||||
}
|
||||
stopRenderDesyncSampleBurst()
|
||||
missingHistoryByPane.clear()
|
||||
pendingPaneKeys.clear()
|
||||
for (const timeoutId of healedCaptureTimeoutIds) {
|
||||
@@ -208,21 +265,15 @@ export function stopTerminalRenderDesyncSentinelForTesting(): void {
|
||||
evidence.length = 0
|
||||
}
|
||||
|
||||
function createCaptureId(paneKey: string): string {
|
||||
const panePart = paneKey.replace(/[^a-zA-Z0-9_-]/g, '-')
|
||||
const nonce = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)
|
||||
return `${Date.now()}-${panePart}-${nonce}`
|
||||
}
|
||||
|
||||
function startSampleBurst(terminal: unknown): void {
|
||||
stopSampleBurst()
|
||||
export function startRenderDesyncSampleBurst(terminal: unknown): void {
|
||||
stopRenderDesyncSampleBurst()
|
||||
burstTerminal = terminal
|
||||
sampleRenderDesyncOnce()
|
||||
burstIntervalId = setInterval(sampleRenderDesyncOnce, SAMPLE_INTERVAL_MS)
|
||||
burstTimeoutId = setTimeout(stopSampleBurst, SAMPLE_BURST_MS)
|
||||
burstTimeoutId = setTimeout(stopRenderDesyncSampleBurst, SAMPLE_BURST_MS)
|
||||
}
|
||||
|
||||
function stopSampleBurst(): void {
|
||||
export function stopRenderDesyncSampleBurst(): void {
|
||||
if (burstIntervalId != null) {
|
||||
clearInterval(burstIntervalId)
|
||||
burstIntervalId = null
|
||||
@@ -235,58 +286,3 @@ function stopSampleBurst(): void {
|
||||
missingHistoryByPane.clear()
|
||||
releaseRenderDesyncReadback()
|
||||
}
|
||||
|
||||
async function persistEvidenceThenRecover(
|
||||
entry: SentinelEvidence,
|
||||
internals: SentinelRenderInternals
|
||||
): Promise<void> {
|
||||
try {
|
||||
const pngDataUrl = entry.livePngDataUrl
|
||||
const bufferText = entry.bufferText
|
||||
if (!pngDataUrl || bufferText == null) {
|
||||
throw new Error('Render-desync evidence payload was released before persistence')
|
||||
}
|
||||
const persisted = await window.api.app.writeTerminalRenderDesyncEvidence({
|
||||
captureId: entry.captureId,
|
||||
phase: 'corrupt',
|
||||
pngDataUrl,
|
||||
metadata: {
|
||||
paneKey: entry.paneKey,
|
||||
when: entry.when,
|
||||
divergence: entry.divergence,
|
||||
paused: entry.paused,
|
||||
rendererState: entry.rendererState,
|
||||
bufferText
|
||||
}
|
||||
})
|
||||
entry.persistedDirectory = persisted.directory
|
||||
} catch (error) {
|
||||
// Why: a failed write must leave the bad pixels intact; recovering here
|
||||
// would destroy the only evidence without producing a durable capture.
|
||||
console.error('[terminal] could not persist render-desync evidence; leaving pane intact', error)
|
||||
pendingPaneKeys.delete(entry.paneKey)
|
||||
return
|
||||
} finally {
|
||||
// Why: persistence owns a successful payload, while a failed write leaves
|
||||
// the live pane intact; neither path should retain duplicate full-canvas data.
|
||||
entry.livePngDataUrl = undefined
|
||||
entry.bufferText = undefined
|
||||
}
|
||||
|
||||
resetAndRefreshAllTerminalWebglAtlases('render-desync')
|
||||
const timeoutId = setTimeout(() => {
|
||||
healedCaptureTimeoutIds.delete(timeoutId)
|
||||
void window.api.app
|
||||
.writeTerminalRenderDesyncEvidence({
|
||||
captureId: entry.captureId,
|
||||
phase: 'healed',
|
||||
pngDataUrl: internals.canvas.toDataURL(),
|
||||
metadata: { when: Date.now() }
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error('[terminal] could not persist healed render reference', error)
|
||||
)
|
||||
.finally(() => pendingPaneKeys.delete(entry.paneKey))
|
||||
}, SAMPLE_INTERVAL_MS)
|
||||
healedCaptureTimeoutIds.add(timeoutId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { forEachLivePaneForDesyncSentinel } from '@/lib/pane-manager/pane-manager-registry'
|
||||
import {
|
||||
captureRenderDesyncNow,
|
||||
startRenderDesyncSampleBurst,
|
||||
stopRenderDesyncSampleBurst
|
||||
} from './terminal-render-desync-sentinel'
|
||||
|
||||
/**
|
||||
* User gestures that arm the render-desync sentinel, split from the detection
|
||||
* loop so each stays under its own roof:
|
||||
* - Cmd/Ctrl+click on a terminal: 10s divergence-sampling burst (the ink
|
||||
* detector, which can only see missing glyphs).
|
||||
* - Cmd/Ctrl+Shift+click: immediate unconditional capture of the clicked
|
||||
* pane, for states the ink detector is blind to (bold-collapse family).
|
||||
*
|
||||
* Off by default; enabled via localStorage so a production build can arm it
|
||||
* from DevTools without a settings-schema change:
|
||||
* localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
|
||||
*/
|
||||
export const RENDER_DESYNC_SENTINEL_FLAG = 'orca:render-desync-sentinel'
|
||||
|
||||
type SentinelPane = {
|
||||
terminal: unknown
|
||||
}
|
||||
|
||||
let clickListener: ((event: MouseEvent) => void) | null = null
|
||||
let sessionArmedOverride: boolean | null = null
|
||||
|
||||
export function maybeStartTerminalRenderDesyncSentinel(): void {
|
||||
if (!isTerminalRenderDesyncSentinelArmed()) {
|
||||
return
|
||||
}
|
||||
installClickListener()
|
||||
}
|
||||
|
||||
function installClickListener(): void {
|
||||
if (clickListener != null) {
|
||||
return
|
||||
}
|
||||
clickListener = (event) => {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
if (event.button !== 0 || (isMac ? !event.metaKey : !event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) {
|
||||
return
|
||||
}
|
||||
let clickedPaneKey: string | null = null
|
||||
let clickedPane: unknown = null
|
||||
forEachLivePaneForDesyncSentinel((paneKey, pane) => {
|
||||
const terminal = (pane as SentinelPane).terminal as { element?: HTMLElement }
|
||||
if (terminal.element?.contains(target)) {
|
||||
clickedPaneKey = paneKey
|
||||
clickedPane = pane
|
||||
}
|
||||
})
|
||||
if (!clickedPane || clickedPaneKey == null) {
|
||||
return
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
captureRenderDesyncNow(clickedPaneKey, clickedPane)
|
||||
return
|
||||
}
|
||||
startRenderDesyncSampleBurst((clickedPane as SentinelPane).terminal)
|
||||
}
|
||||
document.addEventListener('mouseup', clickListener, true)
|
||||
console.warn('[terminal] render-desync sentinel armed (10s post-link bursts + ⇧-capture)')
|
||||
}
|
||||
|
||||
export function isTerminalRenderDesyncSentinelArmed(): boolean {
|
||||
if (sessionArmedOverride != null) {
|
||||
return sessionArmedOverride
|
||||
}
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(RENDER_DESYNC_SENTINEL_FLAG) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff arming surface (hidden-experimental toggle): persists the flag and
|
||||
* arms/disarms the capture gestures live, so no reload is needed. The passive
|
||||
* probes (weight-change crumbs, reveal parity audit) are deliberately not
|
||||
* governed by this — they are content-free and always on.
|
||||
*/
|
||||
export function setTerminalRenderDesyncSentinelArmed(armed: boolean): void {
|
||||
try {
|
||||
const storage = globalThis.localStorage
|
||||
if (!storage) {
|
||||
sessionArmedOverride = armed
|
||||
} else if (armed) {
|
||||
storage.setItem(RENDER_DESYNC_SENTINEL_FLAG, '1')
|
||||
sessionArmedOverride = null
|
||||
} else {
|
||||
storage.removeItem(RENDER_DESYNC_SENTINEL_FLAG)
|
||||
sessionArmedOverride = null
|
||||
}
|
||||
} catch {
|
||||
sessionArmedOverride = armed
|
||||
}
|
||||
if (armed) {
|
||||
installClickListener()
|
||||
} else {
|
||||
removeClickListener()
|
||||
stopRenderDesyncSampleBurst()
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTerminalRenderDesyncTriggerForTesting(): void {
|
||||
removeClickListener()
|
||||
}
|
||||
|
||||
function removeClickListener(): void {
|
||||
if (clickListener != null) {
|
||||
document.removeEventListener('mouseup', clickListener, true)
|
||||
clickListener = null
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
import { applyOrDeferPaneMetricOptions } from '@/lib/pane-manager/pane-metric-options-deferral'
|
||||
import {
|
||||
auditPaneWeightParity,
|
||||
readSentinelWeightProbe
|
||||
} from './terminal-render-desync-weight-probe'
|
||||
|
||||
function fakeTerminal(
|
||||
overrides: {
|
||||
fontWeight?: number | string
|
||||
fontWeightBold?: number | string
|
||||
atlasConfig?: Record<string, unknown>
|
||||
fontProbe?: { count: number; desired: string; actual: string }
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
options: {
|
||||
fontWeight: overrides.fontWeight ?? 500,
|
||||
fontWeightBold: overrides.fontWeightBold ?? 700
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
_renderer: {
|
||||
value: {
|
||||
_charAtlas: {
|
||||
_config: overrides.atlasConfig ?? {
|
||||
fontWeight: 500,
|
||||
fontWeightBold: 700,
|
||||
fontFamily: '"SF Mono", Menlo, monospace',
|
||||
devicePixelRatio: 2
|
||||
},
|
||||
...(overrides.fontProbe
|
||||
? {
|
||||
fontProbeMismatchCount: overrides.fontProbe.count,
|
||||
fontProbeLastMismatch: {
|
||||
desired: overrides.fontProbe.desired,
|
||||
actual: overrides.fontProbe.actual
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeBuffer(rows: { chars: string; bold: boolean }[][]) {
|
||||
return {
|
||||
cursorY: 999,
|
||||
viewportY: 0,
|
||||
getLine: (y: number) => {
|
||||
const row = rows[y]
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
getCell: (x: number) =>
|
||||
row[x] && {
|
||||
getChars: () => row[x].chars,
|
||||
getWidth: () => 1,
|
||||
isBold: () => (row[x].bold ? 1 : 0)
|
||||
},
|
||||
translateToString: () => row.map((c) => c.chars).join('')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('readSentinelWeightProbe', () => {
|
||||
it('reads live options, captured atlas config, and a bold census', () => {
|
||||
const terminal = fakeTerminal({
|
||||
fontWeight: 500,
|
||||
atlasConfig: {
|
||||
fontWeight: 700,
|
||||
fontWeightBold: 700,
|
||||
fontFamily: 'Menlo',
|
||||
devicePixelRatio: 2
|
||||
},
|
||||
fontProbe: { count: 3, desired: '500', actual: 'italic 700 28px Menlo' }
|
||||
})
|
||||
const buffer = fakeBuffer([
|
||||
[
|
||||
{ chars: 'a', bold: false },
|
||||
{ chars: 'b', bold: true },
|
||||
{ chars: ' ', bold: false }
|
||||
],
|
||||
[{ chars: 'c', bold: true }]
|
||||
])
|
||||
|
||||
const probe = readSentinelWeightProbe(terminal, buffer, 2, 3)
|
||||
|
||||
expect(probe).toEqual({
|
||||
optionsFontWeight: '500',
|
||||
optionsFontWeightBold: '700',
|
||||
atlasConfigFontWeight: '700',
|
||||
atlasConfigFontWeightBold: '700',
|
||||
atlasConfigFontFamily: 'Menlo',
|
||||
atlasConfigDevicePixelRatio: 2,
|
||||
boldTextCells: 2,
|
||||
totalTextCells: 3,
|
||||
fontProbeMismatches: 3,
|
||||
fontProbeLastDesired: '500',
|
||||
fontProbeLastActual: 'italic 700 28px Menlo'
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades to nulls when renderer internals are unreachable', () => {
|
||||
const probe = readSentinelWeightProbe({ options: {} }, null, 0, 0)
|
||||
|
||||
expect(probe.optionsFontWeight).toBeNull()
|
||||
expect(probe.atlasConfigFontWeight).toBeNull()
|
||||
expect(probe.fontProbeMismatches).toBeNull()
|
||||
expect(probe.totalTextCells).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('auditPaneWeightParity', () => {
|
||||
const recorder = vi.fn()
|
||||
const settings = { terminalFontWeight: 500, terminalFontWeightBold: 700 }
|
||||
|
||||
beforeEach(() => {
|
||||
recorder.mockClear()
|
||||
setTerminalWebglDiagnosticRecorder(recorder)
|
||||
})
|
||||
|
||||
function paneWith(options: Record<string, unknown>): ManagedPane {
|
||||
return { id: 7, terminal: { options } } as unknown as ManagedPane
|
||||
}
|
||||
|
||||
it('records a crumb for a pane whose live weights diverge from settings', () => {
|
||||
auditPaneWeightParity([paneWith({ fontWeight: 700, fontWeightBold: 700 })], settings)
|
||||
|
||||
expect(recorder).toHaveBeenCalledWith('terminal-weight-parity-mismatch', {
|
||||
paneId: 7,
|
||||
liveFontWeight: '700',
|
||||
liveFontWeightBold: '700',
|
||||
expectedFontWeight: 500,
|
||||
expectedFontWeightBold: 700
|
||||
})
|
||||
})
|
||||
|
||||
it('stays silent for healthy panes', () => {
|
||||
auditPaneWeightParity([paneWith({ fontWeight: 500, fontWeightBold: 700 })], settings)
|
||||
|
||||
expect(recorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips panes with a pending metric deferral (legitimately stale options)', () => {
|
||||
const pane = paneWith({ fontWeight: 700, fontWeightBold: 700 })
|
||||
applyOrDeferPaneMetricOptions(pane, { fontWeight: 500 }, false)
|
||||
|
||||
auditPaneWeightParity([pane], settings)
|
||||
|
||||
expect(recorder).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
|
||||
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
|
||||
import { hasDeferredPaneMetricOptions } from '@/lib/pane-manager/pane-metric-options-deferral'
|
||||
import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types'
|
||||
import type { BufferLike } from './terminal-render-desync-frame'
|
||||
|
||||
/**
|
||||
* Weight-layer forensics for the terminal bold-collapse bug (STA-4042 family):
|
||||
* regular text rasterizing at the bold weight while the byte stream is clean.
|
||||
* One capture of these fields discriminates the candidate layers:
|
||||
* - live options poisoned -> optionsFontWeight is bold-tier
|
||||
* - atlas captured a stale config -> atlasConfig* diverges from options
|
||||
* - renderer buffer cells latched bold -> boldTextCells is viewport-wide
|
||||
* - stuck rasterizer font (failed ctx.font assignment) -> fontProbe* set
|
||||
* (fontProbe fields are maintained by the patched @xterm/addon-webgl atlas)
|
||||
*/
|
||||
export type SentinelWeightProbe = {
|
||||
optionsFontWeight: string | null
|
||||
optionsFontWeightBold: string | null
|
||||
atlasConfigFontWeight: string | null
|
||||
atlasConfigFontWeightBold: string | null
|
||||
atlasConfigFontFamily: string | null
|
||||
atlasConfigDevicePixelRatio: number | null
|
||||
boldTextCells: number
|
||||
totalTextCells: number
|
||||
fontProbeMismatches: number | null
|
||||
fontProbeLastDesired: string | null
|
||||
fontProbeLastActual: string | null
|
||||
}
|
||||
|
||||
type BoldReadableCell = {
|
||||
getChars: () => string
|
||||
getWidth: () => number
|
||||
isBold?: () => number | boolean
|
||||
}
|
||||
|
||||
export function readSentinelWeightProbe(
|
||||
terminal: unknown,
|
||||
buffer: BufferLike | null,
|
||||
rows: number,
|
||||
cols: number
|
||||
): SentinelWeightProbe {
|
||||
const term = terminal as {
|
||||
options?: { fontWeight?: string | number; fontWeightBold?: string | number }
|
||||
_core?: {
|
||||
_renderService?: {
|
||||
_renderer?: {
|
||||
value?: {
|
||||
_charAtlas?: {
|
||||
_config?: {
|
||||
fontWeight?: string | number
|
||||
fontWeightBold?: string | number
|
||||
fontFamily?: string
|
||||
devicePixelRatio?: number
|
||||
}
|
||||
fontProbeMismatchCount?: number
|
||||
fontProbeLastMismatch?: { desired?: string; actual?: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const atlas = term._core?._renderService?._renderer?.value?._charAtlas
|
||||
const config = atlas?._config
|
||||
const census = countBoldTextCells(buffer, rows, cols)
|
||||
return {
|
||||
optionsFontWeight: stringOrNull(term.options?.fontWeight),
|
||||
optionsFontWeightBold: stringOrNull(term.options?.fontWeightBold),
|
||||
atlasConfigFontWeight: stringOrNull(config?.fontWeight),
|
||||
atlasConfigFontWeightBold: stringOrNull(config?.fontWeightBold),
|
||||
atlasConfigFontFamily: stringOrNull(config?.fontFamily),
|
||||
atlasConfigDevicePixelRatio:
|
||||
typeof config?.devicePixelRatio === 'number' ? config.devicePixelRatio : null,
|
||||
boldTextCells: census.bold,
|
||||
totalTextCells: census.total,
|
||||
fontProbeMismatches: atlas?.fontProbeMismatchCount ?? null,
|
||||
fontProbeLastDesired: atlas?.fontProbeLastMismatch?.desired ?? null,
|
||||
fontProbeLastActual: atlas?.fontProbeLastMismatch?.actual ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function stringOrNull(value: string | number | undefined): string | null {
|
||||
return value === undefined ? null : String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal-time audit: any pane whose live weight options diverge from the
|
||||
* settings-resolved pair has been poisoned by some writer, known or unknown.
|
||||
* Runs on the visibility-resume path so every "come back to a finished agent"
|
||||
* reveal — the reported trigger for the bold-collapse bug — checks itself.
|
||||
* Always on (not sentinel-gated): it reads two option fields per pane and
|
||||
* records nothing when healthy, and the poisoning is rare enough that a field
|
||||
* occurrence must never be missed because a flag was unset. Panes with a
|
||||
* pending metric deferral are skipped — their live options are legitimately
|
||||
* stale until the flush, which records its own weight-change crumb.
|
||||
* Settings are passed in (not read from the store) so this stays importable
|
||||
* from lean unit-test graphs.
|
||||
*/
|
||||
export function auditPaneWeightParity(
|
||||
panes: Iterable<ManagedPane>,
|
||||
settings: { terminalFontWeight?: number; terminalFontWeightBold?: number } | null | undefined
|
||||
): void {
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
const expected = resolveTerminalFontWeights(
|
||||
settings.terminalFontWeight,
|
||||
settings.terminalFontWeightBold
|
||||
)
|
||||
for (const pane of panes) {
|
||||
if (hasDeferredPaneMetricOptions(pane)) {
|
||||
continue
|
||||
}
|
||||
const live = pane.terminal.options
|
||||
const liveWeight = stringOrNull(live.fontWeight)
|
||||
const liveBold = stringOrNull(live.fontWeightBold)
|
||||
if (
|
||||
liveWeight === String(expected.fontWeight) &&
|
||||
liveBold === String(expected.fontWeightBold)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
recordTerminalWebglDiagnostic('terminal-weight-parity-mismatch', {
|
||||
paneId: pane.id,
|
||||
liveFontWeight: liveWeight,
|
||||
liveFontWeightBold: liveBold,
|
||||
expectedFontWeight: expected.fontWeight,
|
||||
expectedFontWeightBold: expected.fontWeightBold
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Census of bold-attributed cells across the visible viewport, read from the
|
||||
* renderer's own buffer. The daemon model is clean in every field occurrence,
|
||||
* so a viewport-wide bold census here convicts renderer-side cell corruption;
|
||||
* a clean census with bold pixels convicts the rasterization layer instead.
|
||||
*/
|
||||
function countBoldTextCells(
|
||||
buffer: BufferLike | null,
|
||||
rows: number,
|
||||
cols: number
|
||||
): { bold: number; total: number } {
|
||||
let bold = 0
|
||||
let total = 0
|
||||
if (!buffer) {
|
||||
return { bold, total }
|
||||
}
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const line = buffer.getLine(buffer.viewportY + row)
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
for (let column = 0; column < cols; column++) {
|
||||
const cell = line.getCell(column) as BoldReadableCell | undefined
|
||||
if (!cell) {
|
||||
break
|
||||
}
|
||||
const chars = cell.getChars()
|
||||
if (chars === '' || chars === ' ' || cell.getWidth() === 0) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if (cell.isBold?.()) {
|
||||
bold++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { bold, total }
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
resetTerminalLinkifierHoverState
|
||||
} from '@/lib/pane-manager/terminal-linkifier-hover-reset'
|
||||
import { focusActivePane } from './pane-helpers'
|
||||
import { useAppStore } from '@/store'
|
||||
import { auditPaneWeightParity } from './terminal-render-desync-weight-probe'
|
||||
import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit'
|
||||
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
@@ -83,6 +85,7 @@ export function resumeTerminalVisibility({
|
||||
// change that landed while this tab was hidden has no other repair point.
|
||||
repairPaneWebglCanvasDprMismatch(pane)
|
||||
}
|
||||
auditPaneWeightParity(manager.getPanes(), useAppStore.getState().settings)
|
||||
// Why: intra-worktree tab switches only toggle the overlay. Keeping
|
||||
// synchronous drain and atlas rebuilds off this path avoids racing the
|
||||
// overlay's delayed geometry fit. Still request hidden-output recovery:
|
||||
@@ -103,6 +106,7 @@ export function resumeTerminalVisibility({
|
||||
}
|
||||
enforceTerminalViewportIntents(manager)
|
||||
if (!shouldUseLightTabResume) {
|
||||
auditPaneWeightParity(manager.getPanes(), useAppStore.getState().settings)
|
||||
// Why: this clear wipes the glyph atlas shared with other same-config
|
||||
// terminals; refresh after reset so rebuilt atlases repaint from xterm.
|
||||
resetAndRefreshAllTerminalWebglAtlases('visibility-resume')
|
||||
|
||||
+29
@@ -53,4 +53,33 @@ describe('WebGL diagnostics → freeze breadcrumb ring', () => {
|
||||
kind: 'webgl-context-restore'
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds atlas mismatch storm IPC while retaining the local occurrence count', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
for (let mismatch = 0; mismatch < 10_000; mismatch++) {
|
||||
recordTerminalWebglDiagnostic('atlas-font-probe-mismatch', {
|
||||
desired: '550',
|
||||
actual: '700 14px Menlo'
|
||||
})
|
||||
}
|
||||
|
||||
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(1)
|
||||
expect(getTerminalFreezeBreadcrumbs()).toEqual([
|
||||
expect.objectContaining({ kind: 'atlas-font-probe-mismatch', repeats: 10_000 })
|
||||
])
|
||||
|
||||
vi.advanceTimersByTime(30_000)
|
||||
recordTerminalWebglDiagnostic('atlas-font-probe-mismatch', {
|
||||
desired: '550',
|
||||
actual: '700 14px Menlo'
|
||||
})
|
||||
expect(recordRendererCrashBreadcrumb).toHaveBeenLastCalledWith(
|
||||
'terminal_webgl_diagnostic',
|
||||
expect.objectContaining({ rendererSuppressedSinceLast: 9_999 })
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7141,10 +7141,9 @@
|
||||
"compareBaseBranchUpstream": "Branch upstream"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "Placeholder toggle",
|
||||
"1014ddbfaf": "Does nothing today. Reserved as the first slot for hidden experimental options.",
|
||||
"232cf83de8": "Unlisted toggles for internal testing. Nothing here is supported.",
|
||||
"3e9e827ca5": "Hidden experimental"
|
||||
"7c4e18d2f6": "Arms the bold-glitch capture gestures on this machine: {{samplingShortcut}} starts a sampling burst; {{captureShortcut}} captures pane evidence to disk.",
|
||||
"b09f24a51d": "Terminal render diagnostics",
|
||||
"f2c81d904a": "Hidden experimental settings"
|
||||
},
|
||||
"InputPane": {
|
||||
"db15068196": "Enabled by default on Linux and macOS. Linux uses the system selection clipboard; other platforms use a private buffer.",
|
||||
|
||||
@@ -3918,13 +3918,17 @@
|
||||
"viewDetails": "Ver detalles",
|
||||
"deleteSkill": "Eliminar…"
|
||||
},
|
||||
"SkillsList": { "listLabel": "Skills" },
|
||||
"SkillsList": {
|
||||
"listLabel": "Skills"
|
||||
},
|
||||
"sourceStatus": {
|
||||
"missing": "Carpeta no encontrada",
|
||||
"remoteRepo": "Repositorio remoto — sin analizar",
|
||||
"unavailable": "Sin analizar"
|
||||
},
|
||||
"sources": { "heading": "Carpetas de skills" },
|
||||
"sources": {
|
||||
"heading": "Carpetas de skills"
|
||||
},
|
||||
"sourceKind": {
|
||||
"home": "Inicio",
|
||||
"workspace": "Espacio de trabajo",
|
||||
@@ -6170,10 +6174,9 @@
|
||||
"compareBaseBranchUpstream": "Upstream de la rama"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "Alternar marcador de posición",
|
||||
"1014ddbfaf": "No hace nada hoy. Reservado como primer espacio para opciones experimentales ocultas.",
|
||||
"232cf83de8": "Alternadores no listados para pruebas internas. Aquí no se admite nada.",
|
||||
"3e9e827ca5": "experimental oculto"
|
||||
"7c4e18d2f6": "Activa los gestos de captura del fallo de negrita en esta máquina: {{samplingShortcut}} inicia una ráfaga de muestreo; {{captureShortcut}} captura evidencia del panel en disco.",
|
||||
"b09f24a51d": "Diagnósticos de renderizado del terminal",
|
||||
"f2c81d904a": "Configuración experimental oculta"
|
||||
},
|
||||
"InputPane": {
|
||||
"db15068196": "Habilitado de forma predeterminada en Linux y macOS. Linux usa el portapapeles de selección del sistema; otras plataformas utilizan un búfer privado.",
|
||||
|
||||
@@ -3906,7 +3906,9 @@
|
||||
"searchLinks": "リンクを検索",
|
||||
"deleteSkills": "スキルを削除…"
|
||||
},
|
||||
"SkillShareSelectionControls": { "01c5a15e02": "スキルを共有" },
|
||||
"SkillShareSelectionControls": {
|
||||
"01c5a15e02": "スキルを共有"
|
||||
},
|
||||
"SkillRow": {
|
||||
"updatedUnknown": "日付なし",
|
||||
"pathCopied": "パスをコピーしました",
|
||||
@@ -3916,13 +3918,17 @@
|
||||
"viewDetails": "詳細を表示",
|
||||
"deleteSkill": "削除…"
|
||||
},
|
||||
"SkillsList": { "listLabel": "スキル" },
|
||||
"SkillsList": {
|
||||
"listLabel": "スキル"
|
||||
},
|
||||
"sourceStatus": {
|
||||
"missing": "フォルダーが見つかりません",
|
||||
"remoteRepo": "リモートリポジトリ — 未スキャン",
|
||||
"unavailable": "未スキャン"
|
||||
},
|
||||
"sources": { "heading": "スキルフォルダー" },
|
||||
"sources": {
|
||||
"heading": "スキルフォルダー"
|
||||
},
|
||||
"sourceKind": {
|
||||
"home": "ホーム",
|
||||
"workspace": "ワークスペース",
|
||||
@@ -3944,7 +3950,10 @@
|
||||
"linkOne": "{{count}} 件のリンク",
|
||||
"linkOther": "{{count}} 件のリンク"
|
||||
},
|
||||
"filter": { "allAgents": "すべての Agent", "sharedAgent": "共有 (.agents)" },
|
||||
"filter": {
|
||||
"allAgents": "すべての Agent",
|
||||
"sharedAgent": "共有 (.agents)"
|
||||
},
|
||||
"SkillsSelectionHeader": {
|
||||
"exit": "選択を終了",
|
||||
"exitTooltip": "選択を終了 · Esc",
|
||||
@@ -3953,7 +3962,11 @@
|
||||
"clear": "クリア",
|
||||
"deleteTitle": "削除するスキルを選択"
|
||||
},
|
||||
"SkillDetailDialog": { "agents": "Agent", "updated": "更新日", "copy": "コピー" },
|
||||
"SkillDetailDialog": {
|
||||
"agents": "Agent",
|
||||
"updated": "更新日",
|
||||
"copy": "コピー"
|
||||
},
|
||||
"SkillFreshnessNudge": {
|
||||
"titleOne": "インストール済みの Orca スキルが古くなっています",
|
||||
"titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています",
|
||||
@@ -6183,10 +6196,9 @@
|
||||
"compareBaseBranchUpstream": "ブランチの upstream"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "プレースホルダーの切り替え",
|
||||
"1014ddbfaf": "今日は何もしません。隠された実験的機能オプションの最初のスロットとして予約されています。",
|
||||
"232cf83de8": "内部テスト用の非公開トグル。ここでは何もサポートされていません。",
|
||||
"3e9e827ca5": "隠された実験的機能"
|
||||
"7c4e18d2f6": "このマシンで太字グリッチのキャプチャジェスチャーを有効にします。{{samplingShortcut}} でサンプリングバーストを開始し、{{captureShortcut}} でペインの証拠をディスクに保存します。",
|
||||
"b09f24a51d": "ターミナル描画診断",
|
||||
"f2c81d904a": "隠された実験的機能の設定"
|
||||
},
|
||||
"InputPane": {
|
||||
"db15068196": "Linux および macOS ではデフォルトで有効になっています。 Linux はシステム選択クリップボードを使用します。他のプラットフォームはプライベートバッファを使用します。",
|
||||
|
||||
@@ -6151,10 +6151,9 @@
|
||||
"compareBaseBranchUpstream": "브랜치 업스트림"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "자리 표시자 토글",
|
||||
"1014ddbfaf": "오늘은 아무것도 하지 않습니다. 숨겨진 실험 옵션을 위한 첫 번째 슬롯으로 예약되어 있습니다.",
|
||||
"232cf83de8": "내부 테스트를 위한 비공개 토글입니다. 여기에는 아무것도 지원되지 않습니다.",
|
||||
"3e9e827ca5": "숨겨진 실험"
|
||||
"7c4e18d2f6": "이 컴퓨터에서 굵게 표시 결함 캡처 제스처를 활성화합니다. {{samplingShortcut}}하면 샘플링 버스트가 시작되고, {{captureShortcut}}하면 창 증거를 디스크에 캡처합니다.",
|
||||
"b09f24a51d": "터미널 렌더링 진단",
|
||||
"f2c81d904a": "숨겨진 실험 설정"
|
||||
},
|
||||
"InputPane": {
|
||||
"db15068196": "Linux 및 macOS에서는 기본적으로 활성화되어 있습니다. Linux는 시스템 선택 클립보드를 사용합니다. 다른 플랫폼은 개인 버퍼를 사용합니다.",
|
||||
|
||||
@@ -6194,10 +6194,9 @@
|
||||
"compareBaseBranchUpstream": "分支上游"
|
||||
},
|
||||
"HiddenExperimentalGroup": {
|
||||
"d0f914a528": "占位符切换",
|
||||
"1014ddbfaf": "今天什么也不做。保留作为隐藏实验选项的第一个插槽。",
|
||||
"232cf83de8": "用于内部测试的未列出的切换。这里什么都不支持。",
|
||||
"3e9e827ca5": "隐藏实验"
|
||||
"7c4e18d2f6": "在本机启用粗体故障捕获手势:{{samplingShortcut}} 开始采样突发;{{captureShortcut}} 将窗格证据捕获到磁盘。",
|
||||
"b09f24a51d": "终端渲染诊断",
|
||||
"f2c81d904a": "隐藏实验设置"
|
||||
},
|
||||
"InputPane": {
|
||||
"db15068196": "在 Linux 和 macOS 上默认启用。 Linux使用系统选择剪贴板;其他平台使用私有缓冲区。",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics'
|
||||
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import { toPublicPane } from './pane-public-view'
|
||||
import { canApplyPaneMetricOptions, canMeasurePaneForFit } from './pane-fit-measurability'
|
||||
@@ -165,3 +166,72 @@ describe('canApplyPaneMetricOptions gating', () => {
|
||||
expect(canMeasurePaneForFit(makeSizedPane({ width: 50, height: 600 }, 5))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('metric weight-change forensics', () => {
|
||||
// Field probe for the bold-collapse bug (STA-4042 family): weights never
|
||||
// change in normal operation, so a transition crumb is either the poisoning
|
||||
// or the healing write. These fail if the crumb is removed.
|
||||
it('records prev/next/reason when a weight value actually changes', () => {
|
||||
const recorder = vi.fn()
|
||||
setTerminalWebglDiagnosticRecorder(recorder)
|
||||
const pane = makePane()
|
||||
pane.terminal.options.fontWeight = 500
|
||||
|
||||
applyOrDeferPaneMetricOptions(pane, { fontWeight: 700, fontWeightBold: 700 }, true)
|
||||
|
||||
expect(recorder).toHaveBeenCalledWith('metric-weight-change', {
|
||||
paneId: 1,
|
||||
key: 'fontWeight',
|
||||
prev: '500',
|
||||
next: '700',
|
||||
reason: 'appearance'
|
||||
})
|
||||
expect(recorder).toHaveBeenCalledWith('metric-weight-change', {
|
||||
paneId: 1,
|
||||
key: 'fontWeightBold',
|
||||
prev: null,
|
||||
next: '700',
|
||||
reason: 'appearance'
|
||||
})
|
||||
setTerminalWebglDiagnosticRecorder(null)
|
||||
})
|
||||
|
||||
it('stays silent when the written weights equal the live values', () => {
|
||||
const recorder = vi.fn()
|
||||
setTerminalWebglDiagnosticRecorder(recorder)
|
||||
const pane = makePane()
|
||||
pane.terminal.options.fontWeight = 500
|
||||
pane.terminal.options.fontWeightBold = 700
|
||||
|
||||
applyOrDeferPaneMetricOptions(
|
||||
pane,
|
||||
{ fontWeight: 500, fontWeightBold: 700, fontSize: 15 },
|
||||
true
|
||||
)
|
||||
|
||||
expect(recorder).not.toHaveBeenCalledWith('metric-weight-change', expect.anything())
|
||||
setTerminalWebglDiagnosticRecorder(null)
|
||||
})
|
||||
|
||||
it('labels a flushed deferral as the deferred-flush writer', () => {
|
||||
const recorder = vi.fn()
|
||||
setTerminalWebglDiagnosticRecorder(recorder)
|
||||
const pane = makePane()
|
||||
pane.terminal.options.fontWeight = 700
|
||||
|
||||
applyOrDeferPaneMetricOptions(pane, { fontWeight: 500 }, false)
|
||||
expect(recorder).not.toHaveBeenCalledWith('metric-weight-change', expect.anything())
|
||||
flushDeferredPaneMetricOptions(pane)
|
||||
|
||||
expect(recorder).toHaveBeenCalledWith(
|
||||
'metric-weight-change',
|
||||
expect.objectContaining({
|
||||
key: 'fontWeight',
|
||||
prev: '700',
|
||||
next: '500',
|
||||
reason: 'deferred-flush'
|
||||
})
|
||||
)
|
||||
setTerminalWebglDiagnosticRecorder(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,8 @@ const deferredMetricOptions = new WeakMap<PaneTerminal, PaneMetricOptions>()
|
||||
export function applyOrDeferPaneMetricOptions(
|
||||
pane: ManagedPane,
|
||||
options: PaneMetricOptions,
|
||||
measurable: boolean
|
||||
measurable: boolean,
|
||||
reason = 'appearance'
|
||||
): 'applied' | 'deferred' {
|
||||
if (!measurable) {
|
||||
// Latest wins: a newer settings change while hidden supersedes the pending one.
|
||||
@@ -35,7 +36,7 @@ export function applyOrDeferPaneMetricOptions(
|
||||
return 'deferred'
|
||||
}
|
||||
deferredMetricOptions.delete(pane.terminal)
|
||||
writePaneMetricOptions(pane, options)
|
||||
writePaneMetricOptions(pane, options, reason)
|
||||
return 'applied'
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ export function flushDeferredPaneMetricOptions(pane: ManagedPane): boolean {
|
||||
return false
|
||||
}
|
||||
deferredMetricOptions.delete(pane.terminal)
|
||||
writePaneMetricOptions(pane, pending)
|
||||
writePaneMetricOptions(pane, pending, 'deferred-flush')
|
||||
recordTerminalWebglDiagnostic('metric-options-deferred-flush', { paneId: pane.id })
|
||||
return true
|
||||
}
|
||||
@@ -93,7 +94,11 @@ export function overridePendingPaneMetricOptions(
|
||||
deferredMetricOptions.set(pane.terminal, { ...pending, ...options })
|
||||
}
|
||||
|
||||
function writePaneMetricOptions(pane: ManagedPane, options: PaneMetricOptions): void {
|
||||
function writePaneMetricOptions(
|
||||
pane: ManagedPane,
|
||||
options: PaneMetricOptions,
|
||||
reason: string
|
||||
): void {
|
||||
const target = pane.terminal.options
|
||||
if (options.fontSize !== undefined) {
|
||||
target.fontSize = options.fontSize
|
||||
@@ -102,12 +107,44 @@ function writePaneMetricOptions(pane: ManagedPane, options: PaneMetricOptions):
|
||||
target.fontFamily = options.fontFamily
|
||||
}
|
||||
if (options.fontWeight !== undefined) {
|
||||
recordMetricWeightChange(pane, 'fontWeight', target.fontWeight, options.fontWeight, reason)
|
||||
target.fontWeight = options.fontWeight as typeof target.fontWeight
|
||||
}
|
||||
if (options.fontWeightBold !== undefined) {
|
||||
recordMetricWeightChange(
|
||||
pane,
|
||||
'fontWeightBold',
|
||||
target.fontWeightBold,
|
||||
options.fontWeightBold,
|
||||
reason
|
||||
)
|
||||
target.fontWeightBold = options.fontWeightBold as typeof target.fontWeightBold
|
||||
}
|
||||
if (options.lineHeight !== undefined) {
|
||||
target.lineHeight = options.lineHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Field forensics for the terminal bold-collapse bug (STA-4042 family): weights
|
||||
* never change in normal operation, so any real transition is the poisoning or
|
||||
* healing write. Recorded before the write so `prev` is the pre-write value.
|
||||
*/
|
||||
function recordMetricWeightChange(
|
||||
pane: ManagedPane,
|
||||
key: 'fontWeight' | 'fontWeightBold',
|
||||
prev: string | number | undefined,
|
||||
next: string | number,
|
||||
reason: string
|
||||
): void {
|
||||
if (prev === next) {
|
||||
return
|
||||
}
|
||||
recordTerminalWebglDiagnostic('metric-weight-change', {
|
||||
paneId: pane.id,
|
||||
key,
|
||||
prev: prev === undefined ? null : String(prev),
|
||||
next: String(next),
|
||||
reason
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user