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:
Brennan Benson
2026-08-27 23:36:44 -07:00
committed by GitHub
parent fc8c981103
commit 4bb337741c
28 changed files with 1276 additions and 301 deletions
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')
+3 -3
View File
@@ -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
}
@@ -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
}
}
@@ -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')
@@ -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()
}
})
})
+3 -4
View File
@@ -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.",
+9 -6
View File
@@ -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.",
+21 -9
View File
@@ -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 はシステム選択クリップボードを使用します。他のプラットフォームはプライベートバッファを使用します。",
+3 -4
View File
@@ -6151,10 +6151,9 @@
"compareBaseBranchUpstream": "브랜치 업스트림"
},
"HiddenExperimentalGroup": {
"d0f914a528": "자리 표시자 토글",
"1014ddbfaf": "오늘은 아무것도 하지 않습니다. 숨겨진 실험 옵션을 위한 첫 번째 슬롯으로 예약되어 있습니다.",
"232cf83de8": "내부 테스트를 위한 비공개 토글입니다. 여기에는 아무것도 지원되지 않습니다.",
"3e9e827ca5": "숨겨진 실험"
"7c4e18d2f6": "이 컴퓨터에서 굵게 표시 결함 캡처 제스처를 활성화합니다. {{samplingShortcut}}하면 샘플링 버스트가 시작되고, {{captureShortcut}}하면 창 증거를 디스크에 캡처합니다.",
"b09f24a51d": "터미널 렌더링 진단",
"f2c81d904a": "숨겨진 실험 설정"
},
"InputPane": {
"db15068196": "Linux 및 macOS에서는 기본적으로 활성화되어 있습니다. Linux는 시스템 선택 클립보드를 사용합니다. 다른 플랫폼은 개인 버퍼를 사용합니다.",
+3 -4
View File
@@ -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
})
}