diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8813439db71..743279092d1 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -45,7 +45,9 @@ "normalizes later candidates only when earlier candidates fail", "keeps case expansion offsets and merged highlights stable across query edits", "uses rebuilt metadata independently of the previous snapshot with the same pane id", - "does not retain metadata after its palette entries are released" + "does not retain metadata after its palette entries are released", + "does not retain metadata pinned by a match the caller still holds", + "skips folding for scripts whose lowercase preserves code-unit offsets" ] } ], @@ -56,8 +58,8 @@ "platform": "macos", "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/lib/workspace-tab-agent-snippet-match.test.ts src/renderer/src/lib/workspace-tab-agent-metadata.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-results.test.ts", "result": "passed", - "durationSeconds": 0.462, - "summary": "55 tests across four matcher/metadata/search/result files passed. Independent repeat passed the same 55 tests in 720 ms." + "durationSeconds": 2.82, + "summary": "57 tests across four matcher/metadata/search/result files passed, including a retained-match GC oracle and a non-expanding-script no-fold oracle. Independent repeat passed the same 57 tests in 720 ms." } ], "runtimeBudget": { @@ -77,7 +79,7 @@ "evidence": "Full production search with capped 200-character prompts and 20 history rows: 400 all-Unicode tabs unmatched 189.17 to 6.95 ms warm, 188.38 to 56.64 ms with fresh metadata; 2,000 tabs/10% Unicode 106.75 to 18.04 ms warm, 118.00 to 42.75 ms fresh. Fresh first-prompt match at 400 Unicode tabs 5.43 to 5.53 ms. ASCII full unmatched search 5.567 to 5.505 ms; no ASCII gain claimed and no cache accesses. 400 all-Unicode tabs retain 4,245,152 heap plus 7,075,200 offset-buffer bytes; 2,000 tabs/10% Unicode retain 2,207,456 plus 3,537,600 bytes. All offset buffers release with metadata. Cache is bounded by distinct Unicode text in live immutable metadata, not by query count; no fixed byte cap. Eager preparation was rejected due to cold-query regressions." }, "knownGaps": [ - "No rendered Electron input/frame-latency measurement or live SSH/WSL/Linux/Windows journey. Unicode fixture intentionally includes Turkish uppercase dotted I; prevalence is not production telemetry.", + "No rendered Electron input/frame-latency measurement or live SSH/WSL/Linux/Windows journey. Scope is narrow by construction: U+0130 is the only code point whose toLowerCase lengthens, so folding, the cache and its memory are unreachable for ASCII, CJK, Cyrillic, Greek, accented Latin and emoji. The fixture is therefore Turkish/Azerbaijani-specific and its speedup does not generalize; prevalence is not production telemetry.", "Caching trades temporary open-palette memory for CPU; future in-place growth of metadata strings would retain old folds until that array is discarded, although current producers publish fresh snapshots." ], "promotionCriteria": [ diff --git a/src/renderer/src/lib/workspace-tab-agent-snippet-match.test.ts b/src/renderer/src/lib/workspace-tab-agent-snippet-match.test.ts index f3b8b59efb9..52eb4fa8d3f 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.test.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.test.ts @@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as normalizedText from './palette-match/normalized-text' import { preparePaletteTabQuery } from './palette-match/tab-match' import type { AgentMetadata } from './workspace-tab-agent-metadata' -import { matchWorkspaceTabAgentSnippet } from './workspace-tab-agent-snippet-match' +import { + matchWorkspaceTabAgentSnippet, + type WorkspaceTabAgentSnippetMatch +} from './workspace-tab-agent-snippet-match' function metadata(snippetCandidates: string[], textParts: string[] = []): AgentMetadata { return { paneKey: 'terminal:leaf', snippetCandidates, textParts, lastActivityAt: 1 } @@ -16,6 +19,22 @@ function query(text: string) { return prepared } +/** Drains microtasks between forced collections so a released array cannot linger on a stack slot. */ +async function collect(reference: WeakRef): Promise { + const gc = global.gc + if (!gc) { + throw new Error('This test requires --expose-gc') + } + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setImmediate(resolve)) + gc() + if (!reference.deref()) { + break + } + } + return reference.deref() +} + afterEach(() => vi.restoreAllMocks()) describe('workspace terminal agent snippet search', () => { @@ -100,24 +119,41 @@ describe('workspace terminal agent snippet search', () => { expect(matchWorkspaceTabAgentSnippet(previous, query('nebula'))).toBeNull() }) + it('skips folding for scripts whose lowercase preserves code-unit offsets', () => { + const entries = [ + metadata([ + 'ЗАПУСТИТЬ ТЕРМИНАЛ СЕЙЧАС', + '终端任务调查报告', + 'ÉCRIRE UN TEST ÀÉÎÔÜ', + 'ΕΛΛΗΝΙΚΆ ΚΕΊΜΕΝΟ', + '🚀 emoji terminal 🎉', + 'FULLWIDTH TEXT' + ]) + ] + const prepared = query('missing') + const normalize = vi.spyOn(normalizedText, 'normalizePaletteText') + expect(matchWorkspaceTabAgentSnippet(entries, prepared)).toBeNull() + expect(normalize).not.toHaveBeenCalled() + }) + it('does not retain metadata after its palette entries are released', async () => { - const gc = global.gc - if (!gc) { - throw new Error('This test requires --expose-gc') - } function searchTransientEntries(): WeakRef { const entries = [metadata(['Transient Terminal İnvestigation'])] matchWorkspaceTabAgentSnippet(entries, query('missing')) return new WeakRef(entries) } - const reference = searchTransientEntries() - for (let i = 0; i < 10; i++) { - await new Promise((resolve) => setImmediate(resolve)) - gc() - if (!reference.deref()) { - break - } + expect(await collect(searchTransientEntries())).toBeUndefined() + }) + + it('does not retain metadata pinned by a match the caller still holds', async () => { + let retained: WorkspaceTabAgentSnippetMatch | null = null + function searchTransientEntries(): WeakRef { + const entries = [metadata(['Retained İnvestigation atlas'])] + retained = matchWorkspaceTabAgentSnippet(entries, query('atlas')) + return new WeakRef(entries) } - expect(reference.deref()).toBeUndefined() + const reference = searchTransientEntries() + expect(await collect(reference)).toBeUndefined() + expect(retained).not.toBeNull() }) }) diff --git a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts index 86327a19708..f8be8cd536e 100644 --- a/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts +++ b/src/renderer/src/lib/workspace-tab-agent-snippet-match.ts @@ -43,6 +43,9 @@ function coverAllTokens( tokens: readonly PaletteQueryToken[], agentMetadata: readonly AgentMetadata[] ): MatchRange[] | null { + // Why the cheap path first: this tier scans long agent text for every row the structured + // matcher rejected, and U+0130 is the only code point `toLowerCase` lengthens — so folding + // (and the cache behind it) stays unreachable for ASCII and every other script. const lowered = text.toLowerCase() const folded = lowered.length === text.length ? null : getFoldedSnippet(text, agentMetadata) const haystack = folded ? folded.normalized : lowered