Merge pull request #5864 from Tyriar/tyriar/bufferline_cache

Cache translateToString calls inside BufferLine
This commit is contained in:
Daniel Imms
2026-05-02 06:30:49 -07:00
committed by GitHub
14 changed files with 458 additions and 41 deletions
@@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => {
for (let i = 0; i < buffer.length; i++) {
// Do this intentionally to get content of underlining source
const bufferLine = buffer.getLine(i)._line;
lines.push(JSON.stringify(bufferLine));
lines.push(JSON.stringify(bufferLine, (key, value) => {
// BufferLine caches are internal/transient and can legitimately differ
// across equivalent terminal states.
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
return undefined;
}
return value;
}));
}
return {
x: buffer.cursorX,
@@ -8,14 +8,14 @@ import { assert } from 'chai';
import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, 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/Types';
import { CellData } from 'common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
const dom = new jsdom.JSDOM('');
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM;
@@ -517,7 +517,7 @@ describe('DomRendererRowFactory', () => {
}
function createEmptyLineData(cols: number): IBufferLine {
const lineData = new BufferLine(cols);
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
for (let i = 0; i < cols; i++) {
lineData.setCell(i, NULL_CELL_DATA);
}
@@ -7,10 +7,13 @@ import { assert } from 'chai';
import { ICharacterJoinerService } from 'browser/services/Services';
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferLine } from 'common/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;
@@ -22,7 +25,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(0));
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
const line6 = lineData([['wi']]);
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
@@ -267,7 +270,7 @@ describe('CharacterJoinerService', () => {
type IPartialLineData = ([string] | [string, number]);
function lineData(data: IPartialLineData[]): IBufferLine {
const tline = new BufferLine(0);
const tline = new BufferLine(TEST_STRING_CACHE, 0);
for (let i = 0; i < data.length; ++i) {
const line = data[i][0];
const attr = (data[i][1] || 0) as number;
@@ -9,12 +9,15 @@ import { SelectionModel } from 'browser/selection/SelectionModel';
import { IBufferLine } from 'common/Types';
import { MockBufferService, MockOptionsService, MockCoreService, 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 'browser/TestUtils.test';
import { CellData } from 'common/buffer/CellData';
import { IBuffer } from 'common/buffer/Types';
import { IRenderService } from 'browser/services/Services';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestSelectionService extends SelectionService {
constructor(
bufferService: IBufferService,
@@ -55,7 +58,7 @@ describe('SelectionService', () => {
});
function stringToRow(text: string): IBufferLine {
const result = new BufferLine(text.length);
const result = new BufferLine(TEST_STRING_CACHE, text.length);
for (let i = 0; i < text.length; i++) {
result.setCell(i, createCellData(0, text.charAt(i), 1));
}
@@ -63,7 +66,7 @@ describe('SelectionService', () => {
}
function stringArrayToRow(chars: string[]): IBufferLine {
const line = new BufferLine(chars.length);
const line = new BufferLine(TEST_STRING_CACHE, chars.length);
chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
return line;
}
@@ -118,7 +121,7 @@ describe('SelectionService', () => {
[0, 'o', 1, 'o'.charCodeAt(0)],
[0, 'o', 1, 'o'.charCodeAt(0)]
];
const line = new BufferLine(data.length);
const line = new BufferLine(TEST_STRING_CACHE, 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
+118 -13
View File
@@ -8,22 +8,34 @@ import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { CellData } from 'common/buffer/CellData';
import { ExtendedAttrs } from 'common/buffer/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;
}
}
describe('Buffer', () => {
let optionsService: MockOptionsService;
let bufferService: MockBufferService;
let buffer: Buffer;
let buffer: TestBuffer;
beforeEach(() => {
optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK });
bufferService = new MockBufferService(INIT_COLS, INIT_ROWS);
buffer = new Buffer(true, optionsService, bufferService, new MockLogService());
buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService());
});
describe('constructor', () => {
@@ -151,7 +163,7 @@ describe('Buffer', () => {
describe('no scrollback', () => {
it('should trim from the top of the buffer when the cursor reaches the bottom', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
assert.equal(buffer.lines.maxLength, INIT_ROWS);
buffer.y = INIT_ROWS - 1;
buffer.fillViewportRows();
@@ -1054,7 +1066,7 @@ describe('Buffer', () => {
describe('buffer marked to have no scrollback', () => {
it('should always have a scrollback of 0', () => {
// Test size on initialization
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
buffer = new TestBuffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.lines.maxLength, INIT_ROWS);
// Test size on buffer increase
@@ -1068,7 +1080,7 @@ describe('Buffer', () => {
describe('addMarker', () => {
it('should adjust a marker line when the buffer is trimmed', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
const marker = buffer.addMarker(buffer.lines.length - 1);
assert.equal(marker.line, buffer.lines.length - 1);
@@ -1076,7 +1088,7 @@ describe('Buffer', () => {
assert.equal(marker.line, buffer.lines.length - 2);
});
it('should dispose of a marker if it is trimmed off the buffer', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.markers.length, 0);
const marker = buffer.addMarker(0);
@@ -1088,7 +1100,7 @@ describe('Buffer', () => {
});
it('should call onDispose', () => {
const eventStack: string[] = [];
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.markers.length, 0);
const marker = buffer.addMarker(0);
@@ -1104,7 +1116,7 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
const line = new BufferLine(4);
const line = new BufferLine(TEST_STRING_CACHE, 4);
line.setCell(0, createCellData(0, 'a', 1));
line.setCell(1, createCellData(0, 'b', 1));
line.setCell(2, createCellData(0, 'c', 1));
@@ -1116,7 +1128,7 @@ describe('Buffer', () => {
});
it('should handle a cut-off double width character by including it', () => {
const line = new BufferLine(3);
const line = new BufferLine(TEST_STRING_CACHE, 3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1127,7 +1139,7 @@ describe('Buffer', () => {
});
it('should handle a zero width character in the middle of the string by not including it', () => {
const line = new BufferLine(3);
const line = new BufferLine(TEST_STRING_CACHE, 3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1144,7 +1156,7 @@ describe('Buffer', () => {
});
it('should handle single width emojis', () => {
const line = new BufferLine(2);
const line = new BufferLine(TEST_STRING_CACHE, 2);
line.setCell(0, createCellData(0, '😁', 1));
line.setCell(1, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
@@ -1157,7 +1169,7 @@ describe('Buffer', () => {
});
it('should handle double width emojis', () => {
const line = new BufferLine(2);
const line = new BufferLine(TEST_STRING_CACHE, 2);
line.setCell(0, createCellData(0, '😁', 2));
line.setCell(1, createCellData(0, '', 0));
buffer.lines.set(0, line);
@@ -1168,7 +1180,7 @@ describe('Buffer', () => {
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁');
const line2 = new BufferLine(3);
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
line2.setCell(0, createCellData(0, '😁', 2));
line2.setCell(1, createCellData(0, '', 0));
line2.setCell(2, createCellData(0, 'a', 1));
@@ -1179,6 +1191,99 @@ 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<number, { delay: number, fire: () => 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<typeof setTimeout>;
}) as typeof setTimeout;
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
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();
+14 -5
View File
@@ -4,10 +4,12 @@
*/
import { CircularList, IInsertEvent } from 'common/CircularList';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
@@ -25,7 +27,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
* - cursor position
* - scroll position
*/
export class Buffer implements IBuffer {
export class Buffer extends Disposable implements IBuffer {
public lines: CircularList<IBufferLine>;
public ydisp: number = 0;
public ybase: number = 0;
@@ -50,6 +52,7 @@ export class Buffer implements IBuffer {
private _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0;
private readonly _stringCache: BufferLineStringCache;
constructor(
private _hasScrollback: boolean,
@@ -57,6 +60,7 @@ export class Buffer implements IBuffer {
private _bufferService: IBufferService,
private readonly _logService: ILogService
) {
super();
this._cols = this._bufferService.cols;
this._rows = this._bufferService.rows;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
@@ -64,6 +68,9 @@ export class Buffer implements IBuffer {
this.scrollBottom = this._rows - 1;
this.setupTabStops();
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 {
@@ -93,7 +100,7 @@ export class Buffer implements IBuffer {
}
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped);
}
public get hasScrollback(): boolean {
@@ -138,6 +145,7 @@ export class Buffer implements IBuffer {
* Clears the buffer to it's initial state, discarding all previous data.
*/
public clear(): void {
this._stringCache.clear();
this.ydisp = 0;
this.ybase = 0;
this.y = 0;
@@ -156,6 +164,7 @@ export class Buffer 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;
@@ -190,7 +199,7 @@ export class Buffer 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 it's
// view of the world. Once a line enters scrollback for conpty it remains there
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, 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,
@@ -204,7 +213,7 @@ export class Buffer 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(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
}
}
}
@@ -345,7 +354,7 @@ export class Buffer implements IBuffer {
}
if (this.lines.length < newRows) {
// Add an extra row at the bottom of the viewport
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
}
} else {
if (this.ydisp === this.ybase) {
+100 -1
View File
@@ -4,18 +4,41 @@
*/
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants';
import { BufferLine } from 'common/buffer//BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { CellData } from 'common/buffer/CellData';
import { CharData, IBufferLine } from '../Types';
import { CharData, IBufferLine, ICellData } from '../Types';
import { assert } from 'chai';
import { AttributeData } from 'common/buffer/AttributeData';
import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/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);
}
public get combined(): {[index: number]: string} {
return this._combined;
}
public get cachedString(): string | undefined {
return this._getStringCacheEntry(false)?.value;
}
public set cachedString(value: string | undefined) {
this._getStringCacheEntry(true)!.value = value;
}
public get isCachedStringTrimmed(): boolean {
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
}
public set isCachedStringTrimmed(value: boolean) {
this._getStringCacheEntry(true)!.isTrimmed = value;
}
public toArray(): CharData[] {
const result = [];
for (let i = 0; i < this.length; ++i) {
@@ -807,5 +830,81 @@ describe('BufferLine', function(): void {
assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3));
assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4));
});
it('should cache canonical string translations', () => {
const line = new TestBufferLine(5);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(1, 'b', 1));
line.setCell(2, createCellData(1, 'c', 1));
// Trimmed-only canonical request should cache the trimmed value.
const trimmed = line.translateToString(true, undefined, undefined, undefined);
assert.equal(trimmed, 'abc');
assert.equal(line.cachedString, 'abc');
assert.equal(line.isCachedStringTrimmed, true);
// Non-trimmed canonical request should refresh cache with the full value.
const translated = line.translateToString(false, undefined, undefined, undefined);
assert.equal(translated, 'abc ');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
// Once non-trimmed is cached, trimmed should be derived via trimEnd().
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
line.cachedString = 'cached-non-trimmed ';
line.isCachedStringTrimmed = false;
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed ');
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed');
line.cachedString = 'cached-trimmed';
line.isCachedStringTrimmed = true;
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed');
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc ');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
// Any optional translation argument should bypass cache.
assert.equal(line.translateToString(false, 0, 2, undefined), 'ab');
assert.equal(line.translateToString(true, 0, 2, undefined), 'ab');
});
it('should invalidate cached canonical strings on line mutations', () => {
const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => {
const line = new TestBufferLine(5);
line.fill(createCellData(1, 'a', 1));
line.translateToString(true, undefined, undefined, undefined);
assert.equal(line.cachedString, 'aaaaa');
assert.equal(line.isCachedStringTrimmed, true);
line.translateToString(false, undefined, undefined, undefined);
assert.equal(line.cachedString, 'aaaaa');
assert.equal(line.isCachedStringTrimmed, false);
mutate(line);
assert.equal(line.cachedString, undefined);
assert.equal(line.isCachedStringTrimmed, false);
};
assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)]));
assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0));
assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1)));
assertCacheInvalidated(line => {
const src = new TestBufferLine(5);
src.fill(createCellData(1, 'x', 1));
line.copyFrom(src);
});
assertCacheInvalidated(line => {
const src = new TestBufferLine(5);
src.fill(createCellData(1, 'x', 1));
line.copyCellsFrom(src, 0, 0, 2, false);
});
});
});
});
+74 -3
View File
@@ -44,6 +44,18 @@ const $workCell = new CellData();
/** Factor when to cleanup underlying array buffer after shrinking. */
const CLEANUP_THRESHOLD = 2;
export interface IBufferLineStringCacheEntry {
value: string | undefined;
isTrimmed: boolean;
generation: number;
}
export interface IBufferLineStringCache {
generation: number;
allocateEntry(): IBufferLineStringCacheEntry;
touch?(): void;
}
/**
* Typed array based bufferline implementation.
*
@@ -63,9 +75,15 @@ export class BufferLine implements IBufferLine {
protected _data: Uint32Array;
protected _combined: {[index: number]: string} = {};
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
public length: number;
constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) {
constructor(
protected readonly _stringCache: IBufferLineStringCache,
cols: number,
fillCellData?: ICellData,
public isWrapped: boolean = false
) {
this._data = new Uint32Array(cols * CELL_SIZE);
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
for (let i = 0; i < cols; ++i) {
@@ -98,6 +116,7 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public set(index: number, value: CharData): void {
this._invalidateStringCache();
this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1];
@@ -197,6 +216,7 @@ export class BufferLine implements IBufferLine {
* Set data at `index` to `cell`.
*/
public setCell(index: number, cell: ICellData): void {
this._invalidateStringCache();
if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData;
}
@@ -214,6 +234,7 @@ export class BufferLine implements IBufferLine {
* it gets an optimized access method.
*/
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
this._invalidateStringCache();
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = attrs.extended;
}
@@ -229,6 +250,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();
let content = this._data[index * CELL_SIZE + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add
@@ -255,6 +277,7 @@ export class BufferLine implements IBufferLine {
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
pos %= this.length;
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
@@ -282,6 +305,7 @@ export class BufferLine implements IBufferLine {
}
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
pos %= this.length;
if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) {
@@ -308,6 +332,7 @@ export class BufferLine implements IBufferLine {
}
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
// 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)) {
@@ -347,6 +372,7 @@ export class BufferLine implements IBufferLine {
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
*/
public resize(cols: number, fillCellData: ICellData): boolean {
this._invalidateStringCache();
if (cols === this.length) {
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
@@ -406,6 +432,7 @@ export class BufferLine implements IBufferLine {
/** fill a line with fillCharData */
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
@@ -424,6 +451,7 @@ export class BufferLine implements IBufferLine {
/** alter to a full copy of line */
public copyFrom(line: BufferLine): void {
this._invalidateStringCache();
if (this.length !== line.length) {
this._data = new Uint32Array(line._data);
} else {
@@ -444,7 +472,7 @@ export class BufferLine implements IBufferLine {
/** create a new clone */
public clone(): IBufferLine {
const newLine = new BufferLine(0);
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
newLine._data = new Uint32Array(this._data);
newLine.length = this.length;
for (const el in this._combined) {
@@ -476,6 +504,7 @@ export class BufferLine implements IBufferLine {
}
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
this._invalidateStringCache();
const srcData = src._data;
if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) {
@@ -508,7 +537,8 @@ export class BufferLine implements IBufferLine {
}
/**
* Translates the buffer line to a string.
* Translates the buffer line to a string. Caching only applies to canonical full-line translation
* requests (regardless of `trimRight` value).
*
* @param trimRight Whether to trim any empty cells on the right.
* @param startCol The column to start the string (0-based inclusive).
@@ -521,6 +551,19 @@ 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) {
if (trimRight) {
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
}
if (!stringCacheEntry.isTrimmed) {
return stringCacheEntry.value;
}
}
startCol = startCol ?? 0;
endCol = endCol ?? this.length;
if (trimRight) {
@@ -545,6 +588,34 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.push(startCol);
}
if (isCanonicalRequest) {
const cacheEntry = this._getStringCacheEntry(true)!;
cacheEntry.value = result;
cacheEntry.isTrimmed = !!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;
}
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine';
import { disposableTimeout } from 'common/Async';
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from 'common/Lifecycle';
const enum Constants {
CACHE_TTL_MS = 15000
}
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
public generation: number = 0;
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
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);
}
}
+9 -6
View File
@@ -4,13 +4,16 @@
*/
import { assert } from 'chai';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants';
import { reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
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(4);
const line = new BufferLine(TEST_STRING_CACHE, 4);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -20,7 +23,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(12);
const line = new BufferLine(TEST_STRING_CACHE, 12);
for (let i = 0; i < 12; i += 4) {
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -42,7 +45,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(6);
const line = new BufferLine(TEST_STRING_CACHE, 6);
line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(2, [0, '', 0, 0]);
@@ -56,14 +59,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(6);
const line1 = new BufferLine(TEST_STRING_CACHE, 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(6, undefined, true);
const line2 = new BufferLine(TEST_STRING_CACHE, 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]);
@@ -78,7 +81,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(5);
const line = new BufferLine(TEST_STRING_CACHE, 5);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
+42 -1
View File
@@ -6,7 +6,7 @@
import { assert } from 'chai';
import { BufferSet } from 'common/buffer/BufferSet';
import { Buffer } from 'common/buffer/Buffer';
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
describe('BufferSet', () => {
let bufferSet: BufferSet;
@@ -82,4 +82,45 @@ 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);
});
});
});
+5 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Disposable } from 'common/Lifecycle';
import { Disposable, MutableDisposable } from 'common/Lifecycle';
import { IAttributeData } from 'common/Types';
import { Buffer } from 'common/buffer/Buffer';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
@@ -18,6 +18,8 @@ export class BufferSet extends Disposable implements IBufferSet {
private _normal!: Buffer;
private _alt!: Buffer;
private _activeBuffer!: Buffer;
private readonly _normalBuffer = this._register(new MutableDisposable<Buffer>());
private readonly _altBuffer = this._register(new MutableDisposable<Buffer>());
private readonly _onBufferActivate = this._register(new Emitter<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>());
public readonly onBufferActivate = this._onBufferActivate.event;
@@ -38,11 +40,13 @@ export class BufferSet extends Disposable implements IBufferSet {
public reset(): void {
this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService);
this._normalBuffer.value = this._normal;
this._normal.fillViewportRows();
// The alt buffer should never have scrollback.
// See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService);
this._altBuffer.value = this._alt;
this._activeBuffer = this._normal;
this._onBufferActivate.fire({
activeBuffer: this._normal,
+3 -1
View File
@@ -3,7 +3,9 @@
"compilerOptions": {
"lib": [
"es2015",
"es2016.Array.Include"
"es2016.Array.Include",
"es2019.string",
"es2021.weakref"
],
"outDir": "../../out",
"types": [
+2 -1
View File
@@ -3,7 +3,8 @@
"compilerOptions": {
"lib": [
"es2015",
"es2016.Array.Include"
"es2016.Array.Include",
"es2021.weakref"
],
"outDir": "../../out",
"types": [