diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index ade53f461..e19f6852a 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -6,16 +6,14 @@ import jsdom = require('jsdom'); import { assert } from 'chai'; import { DomRendererRowFactory } from './DomRendererRowFactory'; -import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; +import { DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from '../../../common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from '../../../common/buffer/BufferLine'; -import { BufferLineStringCache } from '../../../common/buffer/BufferLineStringCache'; import { IBufferLine } from '../../../common/buffer/Types'; import { CellData } from '../../../common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from '../../../common/TestUtils.test'; import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from '../../TestUtils.test'; import { TestWidthCache } from './WidthCache.test'; -const TEST_STRING_CACHE = new BufferLineStringCache(); describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; @@ -517,7 +515,7 @@ describe('DomRendererRowFactory', () => { } function createEmptyLineData(cols: number): IBufferLine { - const lineData = new BufferLine(TEST_STRING_CACHE, cols); + const lineData = new BufferLine(cols); for (let i = 0; i < cols; i++) { lineData.setCell(i, NULL_CELL_DATA); } diff --git a/src/browser/services/CharacterJoinerService.test.ts b/src/browser/services/CharacterJoinerService.test.ts index 2e73f035b..b12595bdd 100644 --- a/src/browser/services/CharacterJoinerService.test.ts +++ b/src/browser/services/CharacterJoinerService.test.ts @@ -7,12 +7,10 @@ import { assert } from 'chai'; import { ICharacterJoinerService } from './Services'; import { CharacterJoinerService } from './CharacterJoinerService'; import { BufferLine } from '../../common/buffer/BufferLine'; -import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache'; import { IBufferLine } from '../../common/buffer/Types'; import { CellData } from '../../common/buffer/CellData'; import { MockBufferService, createCellData } from '../../common/TestUtils.test'; -const TEST_STRING_CACHE = new BufferLineStringCache(); describe('CharacterJoinerService', () => { let service: ICharacterJoinerService; @@ -25,7 +23,7 @@ describe('CharacterJoinerService', () => { lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]])); lines.set(3, lineData([['no joined ranges']])); - lines.set(4, new BufferLine(TEST_STRING_CACHE, 0)); + lines.set(4, new BufferLine(0)); lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]])); const line6 = lineData([['wi']]); line6.resize(line6.length + 1, createCellData(0, '¥', 2)); @@ -270,7 +268,7 @@ describe('CharacterJoinerService', () => { type IPartialLineData = ([string] | [string, number]); function lineData(data: IPartialLineData[]): IBufferLine { - const tline = new BufferLine(TEST_STRING_CACHE, 0); + const tline = new BufferLine(0); for (let i = 0; i < data.length; ++i) { const line = data[i][0]; const attr = (data[i][1] || 0) as number; diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index 1f65e909e..59d36d250 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -9,13 +9,11 @@ import { SelectionModel } from '../selection/SelectionModel'; import { IBuffer, IBufferLine } from '../../common/buffer/Types'; import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from '../../common/TestUtils.test'; import { BufferLine } from '../../common/buffer/BufferLine'; -import { BufferLineStringCache } from '../../common/buffer/BufferLineStringCache'; import { IBufferService, IOptionsService } from '../../common/services/Services'; import { MockCoreBrowserService, MockMouseService, MockRenderService } from '../TestUtils.test'; import { CellData } from '../../common/buffer/CellData'; import { IRenderService } from './Services'; -const TEST_STRING_CACHE = new BufferLineStringCache(); class TestSelectionService extends SelectionService { constructor( @@ -60,7 +58,7 @@ describe('SelectionService', () => { }); function stringToRow(text: string): IBufferLine { - const result = new BufferLine(TEST_STRING_CACHE, text.length); + const result = new BufferLine(text.length); for (let i = 0; i < text.length; i++) { result.setCell(i, createCellData(0, text.charAt(i), 1)); } @@ -68,7 +66,7 @@ describe('SelectionService', () => { } function stringArrayToRow(chars: string[]): IBufferLine { - const line = new BufferLine(TEST_STRING_CACHE, chars.length); + const line = new BufferLine(chars.length); chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1))); return line; } @@ -123,7 +121,7 @@ describe('SelectionService', () => { [0, 'o', 1, 'o'.charCodeAt(0)], [0, 'o', 1, 'o'.charCodeAt(0)] ]; - const line = new BufferLine(TEST_STRING_CACHE, data.length); + const line = new BufferLine(data.length); for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i])); buffer.lines.set(0, line); // Ensure wide characters take up 2 columns diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index cbb8cf9ed..477a758b5 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -8,24 +8,14 @@ import { Buffer } from './Buffer'; import { CircularList } from '../CircularList'; import { MockOptionsService, MockBufferService, MockLogService, createCellData } from '../TestUtils.test'; import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { CellData } from './CellData'; -import { ExtendedAttrs } from './AttributeData'; + const INIT_COLS = 80; const INIT_ROWS = 24; const INIT_SCROLLBACK = 1000; -const TEST_STRING_CACHE = new BufferLineStringCache(); -class TestBuffer extends Buffer { - public getStringCache(): BufferLineStringCache { - return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache; - } - - public getStringCacheClearTimeout(): unknown { - return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value; - } -} +class TestBuffer extends Buffer {} describe('Buffer', () => { let optionsService: MockOptionsService; @@ -1202,7 +1192,7 @@ describe('Buffer', () => { describe ('translateBufferLineToString', () => { it('should handle selecting a section of ascii text', () => { - const line = new BufferLine(TEST_STRING_CACHE, 4); + const line = new BufferLine(4); line.setCell(0, createCellData(0, 'a', 1)); line.setCell(1, createCellData(0, 'b', 1)); line.setCell(2, createCellData(0, 'c', 1)); @@ -1214,7 +1204,7 @@ describe('Buffer', () => { }); it('should handle a cut-off double width character by including it', () => { - const line = new BufferLine(TEST_STRING_CACHE, 3); + const line = new BufferLine(3); line.setCell(0, createCellData(0, '語', 2)); line.setCell(1, createCellData(0, '', 0)); line.setCell(2, createCellData(0, 'a', 1)); @@ -1225,7 +1215,7 @@ describe('Buffer', () => { }); it('should handle a zero width character in the middle of the string by not including it', () => { - const line = new BufferLine(TEST_STRING_CACHE, 3); + const line = new BufferLine(3); line.setCell(0, createCellData(0, '語', 2)); line.setCell(1, createCellData(0, '', 0)); line.setCell(2, createCellData(0, 'a', 1)); @@ -1242,7 +1232,7 @@ describe('Buffer', () => { }); it('should handle single width emojis', () => { - const line = new BufferLine(TEST_STRING_CACHE, 2); + const line = new BufferLine(2); line.setCell(0, createCellData(0, '😁', 1)); line.setCell(1, createCellData(0, 'a', 1)); buffer.lines.set(0, line); @@ -1255,7 +1245,7 @@ describe('Buffer', () => { }); it('should handle double width emojis', () => { - const line = new BufferLine(TEST_STRING_CACHE, 2); + const line = new BufferLine(2); line.setCell(0, createCellData(0, '😁', 2)); line.setCell(1, createCellData(0, '', 0)); buffer.lines.set(0, line); @@ -1266,7 +1256,7 @@ describe('Buffer', () => { const str2 = buffer.translateBufferLineToString(0, true, 0, 2); assert.equal(str2, '😁'); - const line2 = new BufferLine(TEST_STRING_CACHE, 3); + const line2 = new BufferLine(3); line2.setCell(0, createCellData(0, '😁', 2)); line2.setCell(1, createCellData(0, '', 0)); line2.setCell(2, createCellData(0, 'a', 1)); @@ -1277,99 +1267,6 @@ describe('Buffer', () => { }); }); - describe('line string cache cleanup', () => { - it('should clear shared cache entries with a single timer', () => { - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - const originalDateNow = Date.now; - let timeoutId = 0; - let now = 0; - const clearedTimeouts: number[] = []; - const scheduledTimeouts = new Map void }>(); - (globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => { - const id = ++timeoutId; - scheduledTimeouts.set(id, { - delay: timeout ?? 0, - fire: () => { - scheduledTimeouts.delete(id); - handler(); - } - }); - return id as ReturnType; - }) as typeof setTimeout; - (globalThis as any).clearTimeout = ((id: ReturnType) => { - const numericId = id as unknown as number; - clearedTimeouts.push(numericId); - scheduledTimeouts.delete(numericId); - }) as typeof clearTimeout; - Date.now = () => now; - try { - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); - - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`); - - const cache = buffer.getStringCache(); - assert.equal(cache.entries.size, 2); - assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - assert.equal(scheduledTimeouts.size, 1); - assert.equal([...scheduledTimeouts.values()][0].delay, 15000); - const initialTimerCreationCount = timeoutId; - - now = 5000; - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(timeoutId, initialTimerCreationCount); - assert.equal(scheduledTimeouts.size, 1); - assert.deepEqual(clearedTimeouts, []); - - now = 15000; - [...scheduledTimeouts.values()][0].fire(); - assert.equal(timeoutId, initialTimerCreationCount + 1); - assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - assert.equal(scheduledTimeouts.size, 1); - assert.equal([...scheduledTimeouts.values()][0].delay, 5000); - - now = 20000; - [...scheduledTimeouts.values()][0].fire(); - - assert.equal(cache.entries.size, 0); - assert.equal(buffer.getStringCacheClearTimeout(), undefined); - - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(cache.entries.size, 1); - } finally { - Date.now = originalDateNow; - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - } - }); - - it('should reset line string cache state on clear and resize', () => { - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - buffer.translateBufferLineToString(0, false); - - const cache = buffer.getStringCache(); - assert.equal(cache.entries.size, 1); - assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - - buffer.clear(); - assert.equal(cache.entries.size, 0); - assert.equal(buffer.getStringCacheClearTimeout(), undefined); - - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); - buffer.translateBufferLineToString(0, false); - assert.equal(cache.entries.size, 1); - - buffer.resize(INIT_COLS - 1, INIT_ROWS); - assert.equal(cache.entries.size, 0); - assert.equal(buffer.getStringCacheClearTimeout(), undefined); - }); - }); - describe('memory cleanup after shrinking', () => { it('should realign memory from idle task execution', async () => { buffer.fillViewportRows(); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index c7d094a43..e3a88bfeb 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -10,7 +10,6 @@ import { ICharset } from '../Types'; import { IAttributeData, IBuffer, IBufferLine, ICellData } from './Types'; import { ExtendedAttrs } from './AttributeData'; import { BufferLine, DEFAULT_ATTR_DATA } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow'; import { CellData } from './CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from './Constants'; @@ -52,7 +51,6 @@ export class Buffer extends Disposable implements IBuffer { private _isClearing: boolean = false; private _memoryCleanupQueue: InstanceType; private _memoryCleanupPosition = 0; - private readonly _stringCache: BufferLineStringCache; constructor( private _hasScrollback: boolean, @@ -70,7 +68,6 @@ export class Buffer extends Disposable implements IBuffer { this._memoryCleanupQueue = new IdleTaskQueue(this._logService); this._register(toDisposable(() => this._memoryCleanupQueue.clear())); this._register(toDisposable(() => this.clearAllMarkers())); - this._stringCache = this._register(new BufferLineStringCache()); } public getNullCell(attr?: IAttributeData): ICellData { @@ -100,7 +97,7 @@ export class Buffer extends Disposable implements IBuffer { } public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { - return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped); + return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped); } public get hasScrollback(): boolean { @@ -145,7 +142,6 @@ export class Buffer extends Disposable implements IBuffer { * Clears the buffer to its initial state, discarding all previous data. */ public clear(): void { - this._stringCache.clear(); this.ydisp = 0; this.ybase = 0; this.y = 0; @@ -164,7 +160,6 @@ export class Buffer extends Disposable implements IBuffer { public resize(newCols: number, newRows: number): void { // store reference to null cell with default attrs const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); - this._stringCache.clear(); // count bufferlines with overly big memory to be cleaned afterwards let dirtyMemoryLines = 0; @@ -199,7 +194,7 @@ export class Buffer extends Disposable implements IBuffer { if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { // Just add the new missing rows on Windows as conpty reprints the screen with its // view of the world. Once a line enters scrollback for conpty it remains there - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + this.lines.push(new BufferLine(newCols, nullCell, false)); } else { if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { // There is room above the buffer and there are no empty elements below the line, @@ -213,7 +208,7 @@ export class Buffer extends Disposable implements IBuffer { } else { // Add a blank line if there is no buffer left at the top to scroll to, or if there // are blank lines after the cursor - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + this.lines.push(new BufferLine(newCols, nullCell, false)); } } } @@ -354,7 +349,7 @@ export class Buffer extends Disposable implements IBuffer { } if (this.lines.length < newRows) { // Add an extra row at the bottom of the viewport - this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); + this.lines.push(new BufferLine(newCols, nullCell, false)); } } else { if (this.ydisp === this.ybase) { diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 7fc12ad76..e14662f8e 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -4,19 +4,16 @@ */ import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from './Constants'; import { BufferLine } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { CellData } from './CellData'; import { CharData, IBufferLine, ICellData } from './Types'; import { assert } from 'chai'; import { AttributeData } from './AttributeData'; import { createCellData, NULL_CELL_DATA, extendedAttributes } from '../TestUtils.test'; -const TEST_STRING_CACHE = new BufferLineStringCache(); - class TestBufferLine extends BufferLine { constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { - super(TEST_STRING_CACHE, cols, fillCellData, isWrapped); + super(cols, fillCellData, isWrapped); } public get combined(): {[index: number]: string} { @@ -24,19 +21,19 @@ class TestBufferLine extends BufferLine { } public get cachedString(): string | undefined { - return this._getStringCacheEntry(false)?.value; + return this._cacheValid ? this._cache : undefined; } public set cachedString(value: string | undefined) { - this._getStringCacheEntry(true)!.value = value; + this._cache = value ?? ''; } public get isCachedStringTrimmed(): boolean { - return this._getStringCacheEntry(false)?.isTrimmed ?? false; + return this._cacheTrimmed; } public set isCachedStringTrimmed(value: boolean) { - this._getStringCacheEntry(true)!.isTrimmed = value; + this._cacheTrimmed = value; } public toArray(): CharData[] { diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 30b4ed3bb..4984f3a49 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -8,7 +8,6 @@ import { AttributeData } from './AttributeData'; import { CellData } from './CellData'; import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from './Constants'; import { stringFromCodePoint } from '../input/TextDecoder'; -import { StringBuilder } from '../StringBuilder'; // Buffer memory layout: // @@ -37,24 +36,20 @@ const enum Cell { BG = 2 // currently unused } + +interface IExtendedAttrsExt extends IExtendedAttrs { + _ext: number; + _urlId: number; +} + + export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); // Work variables to avoid garbage collection let $startIndex = 0; const $workCell = new CellData(); -const $translateToStringBuilder = new StringBuilder(); +const $extended = DEFAULT_ATTR_DATA.extended.clone() as IExtendedAttrsExt; -export interface IBufferLineStringCacheEntry { - value: string | undefined; - isTrimmed: boolean; - generation: number; -} - -export interface IBufferLineStringCache { - generation: number; - allocateEntry(): IBufferLineStringCacheEntry; - touch?(): void; -} /** * Typed array based bufferline implementation. @@ -77,11 +72,14 @@ export class BufferLine implements IBufferLine { protected _combined: {[index: number]: string} = {}; /** Sparse cache; only read when `HAS_EXTENDED` is set in `_data`. */ protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; - protected _stringCacheEntryRef: WeakRef | undefined; public length: number; + /** line text cache */ + protected _cacheValid = false; + protected _cache: string = ''; + protected _cacheTrimmed = false; + constructor( - protected readonly _stringCache: IBufferLineStringCache, cols: number, fillCellData?: ICellData, public isWrapped: boolean = false @@ -118,7 +116,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { - this._invalidateStringCache(); + this._cacheValid = false; this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { this._combined[index] = value[1]; @@ -215,7 +213,11 @@ export class BufferLine implements IBufferLine { } else { // Do not mutate cell.extended in place: it may still reference this line's map entry from a // prior loadCell into a reused CellData (e.g. $workCell during insert/delete). - cell.extended = DEFAULT_ATTR_DATA.extended.clone(); + // We use $extended as blueprint and reset the internals + // mimicking the ctor to avoid a new allocation. + $extended._ext = 0; + $extended._urlId = 0; + cell.extended = $extended; } return cell; } @@ -224,7 +226,7 @@ export class BufferLine implements IBufferLine { * Set data at `index` to `cell`. */ public setCell(index: number, cell: ICellData): void { - this._invalidateStringCache(); + this._cacheValid = false; if (cell.content & Content.IS_COMBINED_MASK) { this._combined[index] = cell.combinedData; } @@ -242,13 +244,14 @@ export class BufferLine implements IBufferLine { * it gets an optimized access method. */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { - this._invalidateStringCache(); + this._cacheValid = false; if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } - this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); - this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg; - this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; + const $idx = index * Constants.CELL_INDICIES; + this._data[$idx + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); + this._data[$idx + Cell.FG] = attrs.fg; + this._data[$idx + Cell.BG] = attrs.bg; } /** @@ -258,7 +261,7 @@ export class BufferLine implements IBufferLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { - this._invalidateStringCache(); + this._cacheValid = false; let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add @@ -285,7 +288,7 @@ export class BufferLine implements IBufferLine { } public insertCells(pos: number, n: number, fillCellData: ICellData): void { - this._invalidateStringCache(); + this._cacheValid = false; pos %= this.length; // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char @@ -313,7 +316,7 @@ export class BufferLine implements IBufferLine { } public deleteCells(pos: number, n: number, fillCellData: ICellData): void { - this._invalidateStringCache(); + this._cacheValid = false; pos %= this.length; if (n < this.length - pos) { for (let i = 0; i < this.length - pos - n; ++i) { @@ -340,7 +343,7 @@ export class BufferLine implements IBufferLine { } public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void { - this._invalidateStringCache(); + this._cacheValid = false; // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { @@ -380,7 +383,7 @@ export class BufferLine implements IBufferLine { * excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD). */ public resize(cols: number, fillCellData: ICellData): boolean { - this._invalidateStringCache(); + this._cacheValid = false; if (cols === this.length) { return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; } @@ -440,7 +443,7 @@ export class BufferLine implements IBufferLine { /** fill a line with fillCharData */ public fill(fillCellData: ICellData, respectProtect: boolean = false): void { - this._invalidateStringCache(); + this._cacheValid = false; // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { for (let i = 0; i < this.length; ++i) { @@ -458,8 +461,7 @@ export class BufferLine implements IBufferLine { } /** alter to a full copy of line */ - public copyFrom(line: BufferLine): void { - this._invalidateStringCache(); + public copyFrom(line: BufferLine, blank?: boolean): void { if (this.length !== line.length) { this._data = new Uint32Array(line._data); } else { @@ -467,16 +469,29 @@ export class BufferLine implements IBufferLine { this._data.set(line._data); } this.length = line.length; - this._copySparseMapsFrom(line); + if (blank) { + // a blank line may never hold combined or extended attrs, + // thus we can skip handling them + this._combined = {}; + this._extendedAttrs = {}; + } else { + this._copySparseMapsFrom(line); + } + this._cache = ''; + this._cacheValid = false; this.isWrapped = line.isWrapped; } /** create a new clone */ - public clone(): IBufferLine { - const newLine = new BufferLine(this._stringCache, 0, undefined, false); + public clone(blank?: boolean): IBufferLine { + const newLine = new BufferLine(0, undefined, false); newLine._data = new Uint32Array(this._data); newLine.length = this.length; - newLine._copySparseMapsFrom(this); + if (!blank) { + // a blank line may never hold combined or extended attrs, + // thus we can skip handling them + newLine._copySparseMapsFrom(this); + } newLine.isWrapped = this.isWrapped; return newLine; } @@ -500,7 +515,7 @@ export class BufferLine implements IBufferLine { } public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { - this._invalidateStringCache(); + this._cacheValid = false; const srcData = src._data; if (applyInReverse) { for (let cell = length - 1; cell >= 0; cell--) { @@ -534,17 +549,13 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { - const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; - if (isCanonicalRequest) { - this._stringCache.touch?.(); - } - const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined; - if (isCanonicalRequest && stringCacheEntry?.value !== undefined) { + const isCanonical = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; + if (isCanonical && this._cacheValid) { if (trimRight) { - return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd(); + return this._cacheTrimmed ? this._cache : this._cache.trimEnd(); } - if (!stringCacheEntry.isTrimmed) { - return stringCacheEntry.value; + if (!this._cacheTrimmed) { + return this._cache; } } startCol = startCol ?? 0; @@ -555,12 +566,12 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.length = 0; } - $translateToStringBuilder.reset(); + const cellContents: string[] = []; while (startCol < endCol) { const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; - $translateToStringBuilder.append(chars); + cellContents.push(chars); if (outColumns) { for (let i = 0; i < chars.length; ++i) { outColumns.push(startCol); @@ -571,39 +582,15 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.push(startCol); } - const result = $translateToStringBuilder.toString(); - $translateToStringBuilder.reset(); - if (isCanonicalRequest) { - const cacheEntry = this._getStringCacheEntry(true)!; - cacheEntry.value = result; - cacheEntry.isTrimmed = !!trimRight; + const result = cellContents.join(''); + if (isCanonical) { + this._cache = result; + this._cacheValid = true; + this._cacheTrimmed = !!trimRight; } return result; } - protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined { - const cachedEntry = this._stringCacheEntryRef?.deref(); - if (cachedEntry) { - if (cachedEntry.generation === this._stringCache.generation) { - return cachedEntry; - } - } - if (!createIfNeeded) { - return undefined; - } - const cacheEntry = this._stringCache.allocateEntry(); - this._stringCacheEntryRef = new WeakRef(cacheEntry); - return cacheEntry; - } - - private _invalidateStringCache(): void { - const cacheEntry = this._getStringCacheEntry(false); - if (cacheEntry) { - cacheEntry.value = undefined; - cacheEntry.isTrimmed = false; - } - } - /** Copy sparse map entries for a single cell when `_data` flags require them. */ private _copyCellMapsFrom(src: BufferLine, srcCol: number, destCol: number): void { const srcStart = srcCol * Constants.CELL_INDICIES; diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts deleted file mode 100644 index 54c97423a..000000000 --- a/src/common/buffer/BufferLineStringCache.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) 2026 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from './BufferLine'; -import { disposableTimeout } from '../Async'; -import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../Lifecycle'; - -const enum Constants { - CACHE_TTL_MS = 15000 -} - -export class BufferLineStringCache extends Disposable implements IBufferLineStringCache { - public generation: number = 0; - public readonly entries: Set = new Set(); - private readonly _clearTimeout = this._register(new MutableDisposable()); - private _lastAccessTimestamp: number = 0; - - constructor() { - super(); - this._register(toDisposable(() => this.entries.clear())); - } - - public touch(): void { - this._scheduleClear(); - } - - public allocateEntry(): IBufferLineStringCacheEntry { - const entry: IBufferLineStringCacheEntry = { - value: undefined, - isTrimmed: false, - generation: this.generation - }; - this.entries.add(entry); - this._scheduleClear(); - return entry; - } - - public clear(): void { - this._clearTimeout.clear(); - this._lastAccessTimestamp = 0; - this.generation++; - for (const entry of this.entries) { - entry.value = undefined; - entry.isTrimmed = false; - } - this.entries.clear(); - } - - private _scheduleClear(): void { - this._lastAccessTimestamp = Date.now(); - if (this._clearTimeout.value) { - return; - } - this._scheduleClearTimeout(Constants.CACHE_TTL_MS); - } - - private _scheduleClearTimeout(timeoutMs: number): void { - this._clearTimeout.value = disposableTimeout(() => { - const elapsed = Date.now() - this._lastAccessTimestamp; - if (elapsed >= Constants.CACHE_TTL_MS) { - this.clear(); - return; - } - this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed); - }, timeoutMs); - } -} diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index 8bcf5bf9c..2a6021eeb 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -5,18 +5,16 @@ import { assert } from 'chai'; import { CircularList } from '../CircularList'; import { BufferLine } from './BufferLine'; -import { BufferLineStringCache } from './BufferLineStringCache'; import { CellData } from './CellData'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from './Constants'; import { reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from './BufferReflow'; import { IBufferLine } from './Types'; -const TEST_STRING_CACHE = new BufferLineStringCache(); describe('BufferReflow', () => { describe('reflowSmallerGetNewLineLengths', () => { it('should return correct line lengths for a small line with wide characters', () => { - const line = new BufferLine(TEST_STRING_CACHE, 4); + const line = new BufferLine(4); line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '', 0, 0]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -26,7 +24,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语'); }); it('should return correct line lengths for a large line with wide characters', () => { - const line = new BufferLine(TEST_STRING_CACHE, 12); + const line = new BufferLine(12); for (let i = 0; i < 12; i += 4) { line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -48,7 +46,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语'); }); it('should return correct line lengths for a string with wide and single characters', () => { - const line = new BufferLine(TEST_STRING_CACHE, 6); + const line = new BufferLine(6); line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(2, [0, '', 0, 0]); @@ -62,14 +60,14 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b'); }); it('should return correct line lengths for a wrapped line with wide and single characters', () => { - const line1 = new BufferLine(TEST_STRING_CACHE, 6); + const line1 = new BufferLine(6); line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line1.set(2, [0, '', 0, 0]); line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]); line1.set(4, [0, '', 0, 0]); line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]); - const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true); + const line2 = new BufferLine(6, undefined, true); line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line2.set(2, [0, '', 0, 0]); @@ -84,7 +82,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b'); }); it('should work on lines ending in null space', () => { - const line = new BufferLine(TEST_STRING_CACHE, 5); + const line = new BufferLine(5); line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '', 0, 0]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -102,7 +100,7 @@ describe('BufferReflow', () => { function createWrappedLines(chars: string): CircularList { const lines = new CircularList(chars.length); for (let i = 0; i < chars.length; i++) { - const line = new BufferLine(TEST_STRING_CACHE, 1); + const line = new BufferLine(1); line.set(0, [0, chars[i], 1, chars.charCodeAt(i)]); line.isWrapped = i > 0; lines.push(line); diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 71a1026dc..7cba74459 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -82,45 +82,4 @@ describe('BufferSet', () => { assert.equal(bufferSet.alt.markers.length, 0); }); }); - - describe('lifecycle', () => { - it('should dispose previous buffers on reset', () => { - const oldNormal = bufferSet.normal as any; - oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - oldNormal.translateBufferLineToString(0, false); - - const oldCache = oldNormal._stringCache; - assert.equal(oldCache.entries.size, 1); - assert.notEqual(oldCache._clearTimeout.value, undefined); - - bufferSet.reset(); - - assert.notEqual(bufferSet.normal, oldNormal); - assert.equal(oldCache.entries.size, 0); - assert.equal(oldCache._clearTimeout.value, undefined); - }); - - it('should dispose both buffers when disposed', () => { - const normal = bufferSet.normal as any; - normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - normal.translateBufferLineToString(0, false); - - bufferSet.activateAltBuffer(); - const alt = bufferSet.alt as any; - alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); - alt.translateBufferLineToString(0, false); - - const normalCache = normal._stringCache; - const altCache = alt._stringCache; - assert.notEqual(normalCache._clearTimeout.value, undefined); - assert.notEqual(altCache._clearTimeout.value, undefined); - - bufferSet.dispose(); - - assert.equal(normalCache.entries.size, 0); - assert.equal(altCache.entries.size, 0); - assert.equal(normalCache._clearTimeout.value, undefined); - assert.equal(altCache._clearTimeout.value, undefined); - }); - }); }); diff --git a/src/common/buffer/Types.ts b/src/common/buffer/Types.ts index 994ca0f43..a7cf0685a 100644 --- a/src/common/buffer/Types.ts +++ b/src/common/buffer/Types.ts @@ -131,8 +131,8 @@ export interface IBufferLine { resize(cols: number, fill: ICellData): boolean; cleanupMemory(): number; fill(fillCellData: ICellData, respectProtect?: boolean): void; - copyFrom(line: IBufferLine): void; - clone(): IBufferLine; + copyFrom(line: IBufferLine, blank?: boolean): void; + clone(blank?: boolean): IBufferLine; getTrimmedLength(): number; getNoBgTrimmedLength(): number; translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string; diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 21b0a163a..7abdac978 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -86,12 +86,12 @@ export class BufferService extends Disposable implements IBufferService { // Insert the line using the fastest method if (bottomRow === buffer.lines.length - 1) { if (willBufferBeTrimmed) { - buffer.lines.recycle().copyFrom(newLine); + buffer.lines.recycle().copyFrom(newLine, true); } else { - buffer.lines.push(newLine.clone()); + buffer.lines.push(newLine.clone(true)); } } else { - buffer.lines.splice(bottomRow + 1, 0, newLine.clone()); + buffer.lines.splice(bottomRow + 1, 0, newLine.clone(true)); } // Only adjust ybase and ydisp when the buffer is not trimmed @@ -113,7 +113,7 @@ export class BufferService extends Disposable implements IBufferService { // scrollback, instead we can just shift them in-place. const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */; buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1); - buffer.lines.set(bottomRow, newLine.clone()); + buffer.lines.set(bottomRow, newLine.clone(true)); } // Move the viewport to the bottom of the buffer unless the user is